diff --git "a/.github/ISSUE_TEMPLATE/\354\235\264\352\270\270\354\226\264\353\225\214-\353\262\204\352\267\270-\353\246\254\355\217\254\355\212\270\360\237\220\236.md" "b/.github/ISSUE_TEMPLATE/\354\235\264\352\270\270\354\226\264\353\225\214-\353\262\204\352\267\270-\353\246\254\355\217\254\355\212\270\360\237\220\236.md" new file mode 100644 index 000000000..97e4c6778 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\354\235\264\352\270\270\354\226\264\353\225\214-\353\262\204\352\267\270-\353\246\254\355\217\254\355\212\270\360\237\220\236.md" @@ -0,0 +1,33 @@ +--- +name: "이길어때 버그 리포트\U0001F41E" +about: 버그 제보를 통해 서비스를 개선해주세요. +title: "[BUG]" +labels: "\U0001F41EBUG" +assignees: minson96 + +--- + +📜 버그 제보 +버그에 대한 간단한 설명을 제공해주세요. + +👟 발생 경위 +1. 버그가 발생하게 된 경위를 설명해주세요 +2. 스크린샷 등 추가적인 정보가 있으면 더 빠르게 대응할 수 있어요 + +👍 예상 동작 +해당 경위를 통해 기대했던 예상 동작에 대해서 설명해주세요 + +👎 실제 동작 +버그와 함께 동작한 실제 화면을 설명해주세요. (스크린샷이 첨부되면 더 좋아요) + +🖥️ 사용 기기 +버그가 발생된 환경의 기기를 간단히 설명해주세요. (예: 아이폰 14, 맥북 m1에어, 갤럭시북...) + +🛜 브라우저 환경 +서비스에 접속한 브라우저 환경에 대해 설명해주세요. (예: 크롬, 사파리, 파이어폭스...) + +📋 추가정보 +서비스 개선을 위해 전달해주실 추가적인 정보가 있다면 제공해주세요! + +👀 비슷한 버그가 이미 발견되어 이슈화되었는지 확인하셨나요? +- [ ] 네 비슷한 버그가 아직 보고되지 않은 것을 확인했습니다. diff --git "a/.github/ISSUE_TEMPLATE/\354\235\264\352\270\270\354\226\264\353\225\214-\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277\342\255\220\357\270\217.md" "b/.github/ISSUE_TEMPLATE/\354\235\264\352\270\270\354\226\264\353\225\214-\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277\342\255\220\357\270\217.md" index 7f1f78cfe..147ef7219 100644 --- "a/.github/ISSUE_TEMPLATE/\354\235\264\352\270\270\354\226\264\353\225\214-\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277\342\255\220\357\270\217.md" +++ "b/.github/ISSUE_TEMPLATE/\354\235\264\352\270\270\354\226\264\353\225\214-\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277\342\255\220\357\270\217.md" @@ -3,7 +3,7 @@ name: 이길어때 이슈 템플릿⭐️ about: 이길어때 개발 시 진행사항 공유를 위한 이슈 템플릿 title: '' labels: '' -assignees: '' +assignees: stoneHee99, minson96 --- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6708e865b..b3dd80309 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,5 @@ ## Motivation 🧐 -- -
## Key Changes 🔑 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index f7dc17d44..000000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,78 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle -name: Java CD with Gradle -on: - push: - branches: [ "main" ] -permissions: - contents: read -defaults: - run: - working-directory: ./backend -jobs: - build: - runs-on: ubuntu-latest - steps: - - ## jdk setting - - uses: actions/checkout@v3 - - name: Set up JDK 21 - uses: actions/setup-java@v3 - with: - java-version: '21' - distribution: 'temurin' - ## docker-compose.yml 생성 후 secret 값 복붙 - - uses: actions/checkout@v3 - - run: touch docker-compose.yml - - run: echo "${{ secrets.DOCKER_COMPOSE }}" > docker-compose.yml - ## application secrets 값 주입 - - name: Set application.yml - run: | - sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@SLAVE_DB_URL@|${{ secrets.SLAVE_DB_URL }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@SLAVE_DB_USERNAME@|${{ secrets.SLAVE_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@SLAVE_DB_PASSWORD@|${{ secrets.SLAVE_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@REDIS_HOST@|${{ secrets.REDIS_HOST }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@REDIS_PORT@|${{ secrets.REDIS_PORT }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@S3_BUCKET@|${{ secrets.S3_BUCKET }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@AWS_ACCESS_KEY@|${{ secrets.AWS_ACCESS_KEY }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@AWS_SECRET_KEY@|${{ secrets.AWS_SECRET_KEY }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./yigil-api/src/main/resources/application.yml - cat ./yigil-api/src/main/resources/* - # Gradle Build를 위한 권한 부여 - - name: Grant execute permission for gradlew - run: chmod +x gradlew - # Gradle Build (test 제외) - - name: Build with Gradle - run: ./gradlew clean build -x test - # DockerHub 로그인 - - name: DockerHub Login - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - # Docker 이미지 빌드 - - name: Docker Image Build - run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }} . --platform=linux/amd64 - # DockerHub Push - - name: DockerHub Push - run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.PROJECT_NAME }} - - # EC2 인스턴스 접속 및 애플리케이션 실행 - - name: Application Run - uses: appleboy/ssh-action@v0.1.6 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_KEY }} - - script: | - sudo docker-compose pull - sudo docker-compose down - sudo docker-compose up -d diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 1a8248980..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,68 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle - -name: Java CI with Gradle - -on: - pull_request: - branches: [ "develop" ] - -permissions: - contents: read - -defaults: - run: - working-directory: ./backend - -jobs: - build: - runs-on: ubuntu-latest - - steps: - ## jdk setting - - uses: actions/checkout@v3 - - name: Set up JDK 21 - uses: actions/setup-java@v3 - with: - java-version: '21' - distribution: 'temurin' - ## application.yml 생성 후 secret 값 복붙 - - - name: make properties files - shell: bash - env: - JASYPT_SECRET_KEY: ${{ secrets.JASYPT_SECRET_KEY }} - KAKAO_TOKEN_INFO_URL: ${{ secrets.KAKAO_TOKEN_INFO_URL }} - run: | - echo "Jasypt-Secret-Key=$JASYPT_SECRET_KEY" > ./yigil-api/src/main/resources/config.properties - echo "kakao.token.info.url=$KAKAO_TOKEN_INFO_URL" > ./yigil-api/src/main/resources/url.properties - - - name: Set application.yml - run: | - sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@SLAVE_DB_URL@|${{ secrets.SLAVE_DB_URL }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@SLAVE_DB_USERNAME@|${{ secrets.SLAVE_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@SLAVE_DB_PASSWORD@|${{ secrets.SLAVE_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@REDIS_HOST@|${{ secrets.REDIS_HOST }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@REDIS_PORT@|${{ secrets.REDIS_PORT }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@S3_BUCKET@|${{ secrets.S3_BUCKET }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@AWS_ACCESS_KEY@|${{ secrets.AWS_ACCESS_KEY }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@AWS_SECRET_KEY@|${{ secrets.AWS_SECRET_KEY }}|g" ./yigil-api/src/main/resources/application.yml - sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./yigil-api/src/main/resources/application.yml - cat ./yigil-api/src/main/resources/* - - - # Gradle Build를 위한 권한 부여 - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - # Gradle Build (test 제외) - - name: Build with Gradle - run: ./gradlew clean build - diff --git a/.github/workflows/cr.yml b/.github/workflows/cr.yml deleted file mode 100644 index 2141793b5..000000000 --- a/.github/workflows/cr.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Code Review - -permissions: - contents: read - pull-requests: write - -on: - pull_request: - - types: [opened, reopened, synchronize] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: anc95/ChatGPT-CodeReview@main - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - LANGUAGE: Korean - PROMPT: 당신은 10년차 시니어 개발자입니다. 우리가 작성한 코드에 문제가 없는지 리뷰해주세요. 대답은 한국어로 작성해주시고 보안 이슈, 버그, 변수명 체크는 꼭 해주세요. 단순한 부분이나 큰 이슈가 없는 부분은 리뷰해주지 않아도 되요. 대답 잘하면 200$ tip 줄게요. diff --git a/.github/workflows/dev-backend-cd.yml b/.github/workflows/dev-backend-cd.yml new file mode 100644 index 000000000..19b91caac --- /dev/null +++ b/.github/workflows/dev-backend-cd.yml @@ -0,0 +1,140 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle +name: Java CD with Gradle +on: + pull_request: + types: ["closed"] + branches: [ "develop" ] +permissions: + contents: read + id-token: write +defaults: + run: + working-directory: ./backend +jobs: + build: + runs-on: ubuntu-latest + if: > + github.event.pull_request.merged == true && + contains(join(github.event.pull_request.labels.*.name, ','), '🛜Backend') + steps: + + # jdk setting + - uses: actions/checkout@v3 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + # application secrets 값 주입 + - name: Set application.yml + run: | + sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_URL@|${{ secrets.SLAVE_DB_URL }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_USERNAME@|${{ secrets.SLAVE_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_PASSWORD@|${{ secrets.SLAVE_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@REDIS_HOST@|${{ secrets.REDIS_HOST }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@REDIS_PORT@|${{ secrets.REDIS_PORT }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@S3_BUCKET@|${{ secrets.S3_BUCKET }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@AWS_ACCESS_KEY@|${{ secrets.AWS_ACCESS_KEY }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@AWS_SECRET_KEY@|${{ secrets.AWS_SECRET_KEY }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@YIGIL_API_PORT@|${{ secrets.YIGIL_API_PORT }}|g" ./yigil-api/src/main/resources/application.yml + - name: Set admin application.yml + run: | + sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_URL@|${{ secrets.SLAVE_DB_URL }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_USERNAME@|${{ secrets.SLAVE_DB_USERNAME }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_PASSWORD@|${{ secrets.SLAVE_DB_PASSWORD }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@REDIS_HOST@|${{ secrets.REDIS_HOST }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@REDIS_PORT@|${{ secrets.REDIS_PORT }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@S3_BUCKET@|${{ secrets.S3_BUCKET }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@AWS_ACCESS_KEY@|${{ secrets.AWS_ACCESS_KEY }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@AWS_SECRET_KEY@|${{ secrets.AWS_SECRET_KEY }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@YIGIL_ADMIN_PORT@|${{ secrets.YIGIL_ADMIN_PORT }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@JWT_SECRET@|${{ secrets.JWT_SECRET }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_HOST@|${{ secrets.MAIL_HOST }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_PORT@|${{ secrets.MAIL_PORT }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_USERNAME@|${{ secrets.MAIL_USERNAME }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_PASSWORD@|${{ secrets.MAIL_PASSWORD }}|g" ./yigil-admin/src/main/resources/application.yml + - name: Set admin application.yml + run: | + sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@PLACE_REGION_BATCH_PORT@|${{ secrets.PLACE_REGION_BATCH_PORT }}|g" ./place-region-batch/src/main/resources/application.yml + # Dockerfile secrets 값 주입 + - name: Set Dockerfile + run: | + sed -i "s|@YIGIL_API_PORT@|${{ secrets.YIGIL_API_PORT }}|g" ./yigil-api/Dockerfile + sed -i "s|@YIGIL_ADMIN_PORT@|${{ secrets.YIGIL_ADMIN_PORT }}|g" ./yigil-admin/Dockerfile + # Gradle Build를 위한 권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + # Gradle Build (test 제외) + - name: Build with Gradle + run: ./gradlew clean build + # AWS 로그인 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + # Docker 이미지 빌드 + - name: Docker image build + run : | + cd yigil-api + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_API_BACK }} . --platform=linux/amd64 + - name: Docker image build + run : | + cd yigil-admin + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_ADMIN_BACK }} . --platform=linux/amd64 + - name: Docker image build + run : | + cd support + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_SUPPORT }} . --platform=linux/amd64 + - name: Docker image build + run : | + cd place-region-batch + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.PLACE_REGION_BATCH }} . --platform=linux/amd64 + - name: Docker image push + run : | + cd yigil-api + docker push ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_API_BACK }} + - name: Docker image push + run : | + cd yigil-admin + docker push ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_ADMIN_BACK }} + - name: Docker image push + run : | + cd support + docker push ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_SUPPORT }} + - name: Docker image push + run : | + cd place-region-batch + docker push ${{ secrets.AWS_ECR }}/${{ secrets.PLACE_REGION_BATCH }} + # EC2 인스턴스 접속 및 애플리케이션 실행 + - name: Application Run + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.EC2_HOST_DEV }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_KEY }} + + script: | + sh ./gitaction.sh diff --git a/.github/workflows/dev-backend-ci.yml b/.github/workflows/dev-backend-ci.yml new file mode 100644 index 000000000..8964360d3 --- /dev/null +++ b/.github/workflows/dev-backend-ci.yml @@ -0,0 +1,99 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle + +name: Java CI with Gradle + +on: + pull_request: + types: [opened] + branches: [ "develop" ] + +permissions: + contents: read + +defaults: + run: + working-directory: ./backend + +jobs: + build: + runs-on: ubuntu-latest + if: contains(join(github.event.pull_request.labels.*.name, ','), '🛜Backend') + steps: + ## jdk setting + - uses: actions/checkout@v3 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + ## application.yml 생성 후 secret 값 복붙 + + - name: make properties files + shell: bash + env: + JASYPT_SECRET_KEY: ${{ secrets.JASYPT_SECRET_KEY }} + KAKAO_TOKEN_INFO_URL: ${{ secrets.KAKAO_TOKEN_INFO_URL }} + run: | + echo "Jasypt-Secret-Key=$JASYPT_SECRET_KEY" > ./yigil-api/src/main/resources/config.properties + echo "kakao.token.info.url=$KAKAO_TOKEN_INFO_URL" > ./yigil-api/src/main/resources/url.properties + + - name: Set api application.yml + run: | + sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_URL@|${{ secrets.SLAVE_DB_URL }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_USERNAME@|${{ secrets.SLAVE_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_PASSWORD@|${{ secrets.SLAVE_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@REDIS_HOST@|${{ secrets.REDIS_HOST }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@REDIS_PORT@|${{ secrets.REDIS_PORT }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@S3_BUCKET@|${{ secrets.S3_BUCKET }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@AWS_ACCESS_KEY@|${{ secrets.AWS_ACCESS_KEY }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@AWS_SECRET_KEY@|${{ secrets.AWS_SECRET_KEY }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@YIGIL_API_PORT@|${{ secrets.YIGIL_API_PORT }}|g" ./yigil-api/src/main/resources/application.yml + # cat ./yigil-api/src/main/resources/* + + - name: Set admin application.yml + run: | + sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_URL@|${{ secrets.SLAVE_DB_URL }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_USERNAME@|${{ secrets.SLAVE_DB_USERNAME }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_PASSWORD@|${{ secrets.SLAVE_DB_PASSWORD }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@REDIS_HOST@|${{ secrets.REDIS_HOST }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@REDIS_PORT@|${{ secrets.REDIS_PORT }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@S3_BUCKET@|${{ secrets.S3_BUCKET }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@AWS_ACCESS_KEY@|${{ secrets.AWS_ACCESS_KEY }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@AWS_SECRET_KEY@|${{ secrets.AWS_SECRET_KEY }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@YIGIL_ADMIN_PORT@|${{ secrets.YIGIL_ADMIN_PORT }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@JWT_SECRET@|${{ secrets.JWT_SECRET }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_HOST@|${{ secrets.MAIL_HOST }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_PORT@|${{ secrets.MAIL_PORT }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_USERNAME@|${{ secrets.MAIL_USERNAME }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_PASSWORD@|${{ secrets.MAIL_PASSWORD }}|g" ./yigil-admin/src/main/resources/application.yml + - name: Set admin application.yml + run: | + sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@PLACE_REGION_BATCH_PORT@|${{ secrets.PLACE_REGION_BATCH_PORT }}|g" ./place-region-batch/src/main/resources/application.yml + # cat ./yigil-admin/src/main/resources/* + + + # Gradle Build를 위한 권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Gradle Build (test 제외) + - name: Build with Gradle + run: ./gradlew clean build test + diff --git a/.github/workflows/dev-frontend-cd.yml b/.github/workflows/dev-frontend-cd.yml new file mode 100644 index 000000000..afa5fabb0 --- /dev/null +++ b/.github/workflows/dev-frontend-cd.yml @@ -0,0 +1,63 @@ +name: Frontend CD + +on: + pull_request: + types: ["closed"] + branches: ["develop"] +permissions: + contents: read + id-token: write +defaults: + run: + working-directory: ./frontend + +jobs: + build: + runs-on: ubuntu-latest + if: > + github.event.pull_request.merged == true && + contains(join(github.event.pull_request.labels.*.name, ','), '💻Frontend') + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: frontend + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: "**/package-lock.json" + - name: npm install library + run: npm ci + # npm 테스트 진행 + - name: Run test + run: npm run test + # AWS 로그인 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + # Docker 이미지 빌드 + - name: Docker image build + run : | + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_API_FRONT }} . --platform=linux/amd64 + - name: Docker image push + run : | + docker push ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_API_FRONT }} + # EC2 인스턴스 접속 및 애플리케이션 실행 + - name: Application Run + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.EC2_HOST_DEV }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_KEY }} + + script: | + sh ./gitaction.sh diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/dev-frontend-ci.yml similarity index 61% rename from .github/workflows/frontend-ci.yml rename to .github/workflows/dev-frontend-ci.yml index c40731482..d7a2d06d7 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/dev-frontend-ci.yml @@ -1,9 +1,8 @@ name: Frontend CI on: - push: pull_request: - branches: [ "develop" ] + branches: ["develop"] defaults: run: @@ -12,20 +11,19 @@ defaults: jobs: test: runs-on: ubuntu-latest + if: contains(join(github.event.pull_request.labels.*.name, ','), '💻Frontend') timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: - sparse-checkout: - frontend + sparse-checkout: frontend - uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' - cache-dependency-path: '**/package-lock.json' + cache: "npm" + cache-dependency-path: "**/package-lock.json" - run: npm ci - name: Run test run: npm run test - diff --git a/.github/workflows/prod-backend-cd.yml b/.github/workflows/prod-backend-cd.yml new file mode 100644 index 000000000..4ad1195ca --- /dev/null +++ b/.github/workflows/prod-backend-cd.yml @@ -0,0 +1,143 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle +name: Java CD with Gradle +on: + pull_request: + types: [ "closed" ] + branches: [ "main" ] +permissions: + contents: read + id-token: write +defaults: + run: + working-directory: ./backend +jobs: + build: + runs-on: ubuntu-latest + if: > + github.event.pull_request.merged == true && + contains(join(github.event.pull_request.labels.*.name, ','), '🛜Backend') + steps: + ## jdk setting + - uses: actions/checkout@v3 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + ## application secrets 값 주입 + - name: Set api application.yml + run: | + sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL_PROD }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_URL@|${{ secrets.SLAVE_DB_URL_PROD }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_USERNAME@|${{ secrets.SLAVE_DB_USERNAME }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_PASSWORD@|${{ secrets.SLAVE_DB_PASSWORD }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@REDIS_HOST@|${{ secrets.REDIS_HOST }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@REDIS_PORT@|${{ secrets.REDIS_PORT }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@S3_BUCKET@|${{ secrets.S3_BUCKET }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@AWS_ACCESS_KEY@|${{ secrets.AWS_ACCESS_KEY }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@AWS_SECRET_KEY@|${{ secrets.AWS_SECRET_KEY }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./yigil-api/src/main/resources/application.yml + sed -i "s|@YIGIL_API_PORT@|${{ secrets.YIGIL_API_PORT }}|g" ./yigil-api/src/main/resources/application.yml + # cat ./yigil-api/src/main/resources/* + - name: Set admin application.yml + run: | + sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL_PROD }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_URL@|${{ secrets.SLAVE_DB_URL_PROD }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_USERNAME@|${{ secrets.SLAVE_DB_USERNAME }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLAVE_DB_PASSWORD@|${{ secrets.SLAVE_DB_PASSWORD }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@REDIS_HOST@|${{ secrets.REDIS_HOST }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@REDIS_PORT@|${{ secrets.REDIS_PORT }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@S3_BUCKET@|${{ secrets.S3_BUCKET }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@AWS_ACCESS_KEY@|${{ secrets.AWS_ACCESS_KEY }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@AWS_SECRET_KEY@|${{ secrets.AWS_SECRET_KEY }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@YIGIL_ADMIN_PORT@|${{ secrets.YIGIL_ADMIN_PORT }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@JWT_SECRET@|${{ secrets.JWT_SECRET }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_HOST@|${{ secrets.MAIL_HOST }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_PORT@|${{ secrets.MAIL_PORT }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_USERNAME@|${{ secrets.MAIL_USERNAME }}|g" ./yigil-admin/src/main/resources/application.yml + sed -i "s|@MAIL_PASSWORD@|${{ secrets.MAIL_PASSWORD }}|g" ./yigil-admin/src/main/resources/application.yml + # cat ./yigil-admin/src/main/resources/* + - name: Set admin application.yml + run: | + sed -i "s|@MASTER_DB_URL@|${{ secrets.MASTER_DB_URL_PROD }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@MASTER_DB_USERNAME@|${{ secrets.MASTER_DB_USERNAME }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@MASTER_DB_PASSWORD@|${{ secrets.MASTER_DB_PASSWORD }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@SLACK_WEBHOOK_URI@|${{ secrets.SLACK_WEBHOOK_URI }}|g" ./place-region-batch/src/main/resources/application.yml + sed -i "s|@PLACE_REGION_BATCH_PORT@|${{ secrets.PLACE_REGION_BATCH_PORT }}|g" ./place-region-batch/src/main/resources/application.yml + ## Dockerfile secrets 값 주입 + - name: Set Dockerfile + run: | + sed -i "s|@YIGIL_API_PORT@|${{ secrets.YIGIL_API_PORT }}|g" ./yigil-api/Dockerfile + sed -i "s|@YIGIL_ADMIN_PORT@|${{ secrets.YIGIL_ADMIN_PORT }}|g" ./yigil-admin/Dockerfile + sed -i "s|@PLACE_REGION_BATCH_PORT@|${{ secrets.PLACE_REGION_BATCH_PORT }}|g" ./yigil-admin/Dockerfile + # Gradle Build를 위한 권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + # Gradle Build (test 제외) + - name: Build with Gradle + run: ./gradlew clean build + ## AWS에 로그인합니다. aws-region은 서울로 설정(ap-northeast-2)했습니다 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + ## ECR에 로그인합니다 + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + # Docker 이미지 빌드 + - name: Docker image build + run : | + cd yigil-api + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_API_BACK_PROD }} . --platform=linux/amd64 + - name: Docker image build + run : | + cd yigil-admin + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_ADMIN_BACK_PROD }} . --platform=linux/amd64 + - name: Docker image build + run : | + cd support + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_SUPPORT_PROD }} . --platform=linux/amd64 + - name: Docker image build + run : | + cd place-region-batch + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.PLACE_REGION_BATCH_PROD }} . --platform=linux/amd64 + - name: Docker image push + run : | + cd yigil-api + docker push ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_API_BACK_PROD }} + - name: Docker image push + run : | + cd yigil-admin + docker push ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_ADMIN_BACK_PROD }} + - name: Docker image push + run : | + cd support + docker push ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_SUPPORT_PROD }} + - name: Docker image push + run : | + cd place-region-batch + docker push ${{ secrets.AWS_ECR }}/${{ secrets.PLACE_REGION_BATCH_PROD }} + + # EC2 인스턴스 접속 및 애플리케이션 실행 + - name: Application Run + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.EC2_HOST_PROD }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_KEY }} + + script: | + sh ./gitaction.sh diff --git a/.github/workflows/prod-frontend-cd.yml b/.github/workflows/prod-frontend-cd.yml new file mode 100644 index 000000000..d00771c5c --- /dev/null +++ b/.github/workflows/prod-frontend-cd.yml @@ -0,0 +1,62 @@ +name: Frontend CD + +on: + push: + branches: ["main"] +permissions: + contents: read + id-token: write +defaults: + run: + working-directory: ./frontend + +jobs: + build: + runs-on: ubuntu-latest + if: > + github.event.pull_request.merged == true && + contains(join(github.event.pull_request.labels.*.name, ','), '💻Frontend') + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: frontend + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: "**/package-lock.json" + - name: npm install library + run: npm ci + # npm 테스트 진행 + - name: Run test + run: npm run test + # AWS 로그인 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ap-northeast-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + # Docker 이미지 빌드 + - name: Docker image build + run : | + docker build -t ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_API_FRONT }} . --platform=linux/amd64 + - name: Docker image push + run : | + docker push ${{ secrets.AWS_ECR }}/${{ secrets.YIGIL_API_FRONT }} + # EC2 인스턴스 접속 및 애플리케이션 실행 + - name: Application Run + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.EC2_HOST_PROD }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_KEY }} + + script: | + sh ./gitaction.sh diff --git a/.gitignore b/.gitignore index ddb919823..458993db7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,48 +1,362 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea +ll# Created by https://www.toptal.com/developers/gitignore/api/intellij,java,macos,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=intellij,java,macos,windows + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format *.iws -*.iml -*.ipr + +# IntelliJ out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ -` -### application*.yml ### -src/main/resources/*.yml -application.yml +# mpeltonen/sbt-idea plugin +.idea +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### macOS ### +# General .DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/intellij,java,macos,windows + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### react ### +.DS_* +**/*.backup.* +**/*.back.* + +node_modules + +*.sublime* + +psd +thumb +sketch + +# End of https://www.toptal.com/developers/gitignore/api/node,react -.github/ -.gradle/ -.idea/ +backend/support/domain/src/main/generated/* +*.yml diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..b33bc7b39 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "admin-frontend"] + path = admin-frontend + url = https://github.com/Kernel360/f1-yigil-admin-frontend.git diff --git a/README.md b/README.md index e13ded0cc..a308cff63 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,62 @@ # f1-Yigil + +## 이길 로그 -## 이길 +## 프로젝트 기획 및 목적 + +"이길 로그" 서비스는 지도 기반의 일정 및 장소 공유 플랫폼으로, 사용자들은 여행이나 다양한 활동 +중에 방문한 장소들을 지도 상에 마킹하고 그에 대한 일정을 작성할 수 있습니다. 각 일정에는 사용자가 +촬영한 사진과 함께 제목, 평점, 그리고 간단한 내용을 추가하여 기록할 수 있습니다. 이를 통해 +사용자들은 자신의 경험을 다양한 면에서 기록하고 공유할 수 있습니다. + +## 배포 주소 +[이길 로그](https://yigil.co.kr) + +## 주요 기능 + +### 별점 기능 +redis 캐싱을 활용하여 사용자들이 빠르게 볼 수 있도록 구현되어있다. 추후에 batch를 통해 실시간이 아닌 +주기적인 갱신을 진행하려고 합니다. 혹시 여기서 비효율적이거나 더 나은 방법이 있는지 궁금합니다. +추가적으로 초기 batch 기간을 어느정도로 정해야 하는지 궁금합니다. + +### 팔로잉 팔로워 기능 +followerCount와 followingCount를 redis를 활용하여 접근할 수 있도록 캐싱이 적용됩니다. + +### 어드민 페이지 기능 +어드민 페이지는 OAuth + 세션방식이 아니라 jwt 토큰 인증방식을 사용합니다. + +### 알림 기능 +webflux를 활용하여 SSE 알림을 구현하였습니다. + +### 소셜 로그인 기능 +로그인은 카카오 소셜로그인만 있습니다. +OAuth를 통해 카카오나 구글에서 토큰을 받아서 인증을 하고 추가정보를 받아서 회원가입을 진행하고 +연결 유지는 세션을 통해서 진행합니다. + +## 집중적으로 코드리뷰 필요한 부분 + +### 이벤트 기반 파일 업로드 과정 +파일을 비동기식으로 업로드를 진행하는데 이 부분을 용도에 맞게 적용이 되었는지 궁금합니다. +고쳐야 하거나 개선을 할 부분이 있는지 궁금합니다. + +event -> yigil-api/src/main/java/kr/co/yigil에 file 도메인에 있습니다. +attachFile -> support안에 있다. +파일 업로드가 필요하면 파일 업로드 이벤트를 만들고 그 안에 파일 콜백함수를 만듭니다. +그러면 이벤트가 publisher를 통해 이벤트를 발행하면 event listener에서 s3에 파일 +을 업로드 한 후 파일의 주소를 담은 attachfile을 만들어 callback 함수를 실행해줍니다. +그 이후에 attachfile을 받아 파일에 저장하거나 spot을 save하거나 update를 수행합니다. + +### 게시글 관련 기능 +구조를 먼저 설명 드리면 travel을 상속받는 spot과 course가 있고 travel에는 공통적으로 +게시글의 타이틀이나 내용, 고유 ID, 멤버 정보 등이 있습니다. 그리고 course는 spot들이 모여서 구성되어 있습니다. + +Spot에는 좌표값을 Point 객체로 얻고 좌표값에 위치한 장소에 대한 각종 정보를 place 객체로 포함하고 있습니다. +file의 주소나 정보를 담은 attachfile도 포함하고 있습니다. + +나중에는 거리 순으로 근처 spot들을 조회할 예정인데 거리를 구하는 것이 너무 비효율적인 것 같아 +어떻게 처리하면 효율적일지 고민입니다. + +### 좋아요 기능 +Redis에 캐싱을 하는 과정에 있어서 동시에 캐싱 요청을 처리했을 때 데이터 정합성이 보장되지 않은 채로 +캐싱될 수 있지 않을까 하는 걱정이 들었습니다. +ㅇ diff --git a/admin-frontend/yigil-admin/src/components/snippet/UserInfo.tsx b/admin-frontend/yigil-admin/src/components/snippet/UserInfo.tsx new file mode 100644 index 000000000..9a74277d4 --- /dev/null +++ b/admin-frontend/yigil-admin/src/components/snippet/UserInfo.tsx @@ -0,0 +1,90 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { EnvelopeOpenIcon } from "@radix-ui/react-icons"; +import { Link } from "react-router-dom"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +interface UserInfoProps { + username: string; + profile_url: string; +} + +const UserInfo: React.FC = () => { + const [user, setUser] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + const accessToken = getCookie("accessToken"); + + if (accessToken) { + fetch("https://yigil.co.kr/admin/api/v1/admins/info", { + headers: { + Authorization: `${accessToken}`, + }, + }) + .then((response) => response.json()) + .then((data) => setUser(data)) + .catch((error) => console.error("Error:", error)); + } + }, []); + + const deleteCookie = (name: string) => { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + }; + + const handleLogout = () => { + deleteCookie("accessToken"); + deleteCookie("refreshToken"); + + navigate("/admin/login"); + }; + + return ( +
+ {user ? ( + + + + + {user.username[0]} + + + +
+
+

{user.username}

+

+ 관리자님, 환영합니다! +

+
+
+ +
+
+
+
+ ) : ( + + + + )} +
+ ); +}; + +export default UserInfo; + +function getCookie(name: string): string | undefined { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(";").shift(); +} diff --git a/admin-frontend/yigil-admin/src/components/snippet/withAuthProtection.tsx b/admin-frontend/yigil-admin/src/components/snippet/withAuthProtection.tsx new file mode 100644 index 000000000..6af7872a6 --- /dev/null +++ b/admin-frontend/yigil-admin/src/components/snippet/withAuthProtection.tsx @@ -0,0 +1,28 @@ +import React, { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function withAuthProtection>( + WrappedComponent: React.ComponentType +) { + return function ProtectedComponent(props: T) { + const navigate = useNavigate(); + + useEffect(() => { + const accessToken = getCookie("accessToken"); + if (!accessToken) { + navigate("/admin/login"); + } + }, [navigate]); + + return ; + }; +} + +export default withAuthProtection; + +function getCookie(name: string): string | undefined { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(";").shift(); +} diff --git a/admin-frontend/yigil-admin/vite.config.ts b/admin-frontend/yigil-admin/vite.config.ts new file mode 100644 index 000000000..7bbdbbeed --- /dev/null +++ b/admin-frontend/yigil-admin/vite.config.ts @@ -0,0 +1,18 @@ +import path from "path"; +import react from "@vitejs/plugin-react"; +import svgr from "vite-plugin-svgr"; +import { defineConfig } from "vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + // server: { + // port: 5173, + // host: "0.0.0.0", + // }, + plugins: [react(), svgr()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/backend/.gitignore b/backend/.gitignore index c0e2cb3df..f984fb72e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -169,4 +169,11 @@ gradle-app.setting # End of https://www.toptal.com/developers/gitignore/api/java,gradle,intellij -src/test/java/kr/co/yigil/global/config/JasyptApplicationTest.java \ No newline at end of file +src/test/java/kr/co/yigil/global/config/JasyptApplicationTest.java + +# Q class +support/src/main/generated/* + +place-region-batch/src/main/resources/application.yml +yigil-api/src/main/resources/application.yml +yigil-admin/src/main/resources/application.yml \ No newline at end of file diff --git a/backend/build.gradle b/backend/build.gradle index 0710b22d3..8507ad611 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -1,10 +1,15 @@ +buildscript { + ext { + queryDslVersion = "5.1.0" + } +} + plugins { id 'java' id 'org.springframework.boot' version '3.2.0' id 'io.spring.dependency-management' version '1.1.4' id 'org.jetbrains.kotlin.jvm' id "org.asciidoctor.jvm.convert" version "3.3.2" - id 'jacoco' } group = 'kr.co' @@ -20,10 +25,6 @@ ext { set('snippetsDir', file("build/generated-snippets")) } -jacoco { - toolVersion = '0.8.5' -} - allprojects { repositories { mavenCentral() @@ -35,20 +36,16 @@ tasks.named('test') { useJUnitPlatform() } -tasks.withType(Test) { - jacoco.includeNoLocationClasses = true -} kotlin { jvmToolchain(21) } bootJar { - enabled = false + enabled = false } jar { - enabled = true + enabled = true } - diff --git a/backend/place-region-batch/Dockerfile b/backend/place-region-batch/Dockerfile new file mode 100644 index 000000000..694fef1b5 --- /dev/null +++ b/backend/place-region-batch/Dockerfile @@ -0,0 +1,9 @@ +FROM openjdk:21-jdk + +WORKDIR /app + +COPY build/libs/place-region-batch-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 8001 + +CMD ["java", "-jar", "app.jar"] diff --git a/backend/place-region-batch/build.gradle b/backend/place-region-batch/build.gradle new file mode 100644 index 000000000..547a9ad92 --- /dev/null +++ b/backend/place-region-batch/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' + id 'org.jetbrains.kotlin.jvm' +} + +bootJar { + mainClass = 'kr.co.yigil.BatchApplication' +} + +group = 'kr.co.yigil' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':support:log') + implementation project(':support:domain') + + implementation 'org.springframework.boot:spring-boot-starter-batch' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'org.postgresql:postgresql' + implementation 'org.postgresql:postgresql' + implementation 'org.hibernate:hibernate-core:6.4.0.Final' + implementation 'org.hibernate:hibernate-spatial:6.4.0.Final' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + implementation 'com.github.maricn:logback-slack-appender:1.6.1' + + implementation 'org.locationtech.jts:jts-core:1.19.0' + implementation 'org.locationtech.jts.io:jts-io-common:1.19.0' + + implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/backend/place-region-batch/src/main/java/kr/co/yigil/BatchApplication.java b/backend/place-region-batch/src/main/java/kr/co/yigil/BatchApplication.java new file mode 100644 index 000000000..45b23ad19 --- /dev/null +++ b/backend/place-region-batch/src/main/java/kr/co/yigil/BatchApplication.java @@ -0,0 +1,11 @@ +package kr.co.yigil; + +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +@SpringBootApplication +public class BatchApplication { + public static void main(String[] args) { + SpringApplication.run(BatchApplication.class, args); + } +} diff --git a/backend/place-region-batch/src/main/java/kr/co/yigil/batch/config/QueryDslConfig.java b/backend/place-region-batch/src/main/java/kr/co/yigil/batch/config/QueryDslConfig.java new file mode 100644 index 000000000..04a420abd --- /dev/null +++ b/backend/place-region-batch/src/main/java/kr/co/yigil/batch/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package kr.co.yigil.batch.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class QueryDslConfig { + + private final EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory(){ + return new JPAQueryFactory(em); + } +} \ No newline at end of file diff --git a/backend/place-region-batch/src/main/java/kr/co/yigil/batch/job/DemographicPlaceJobConfig.java b/backend/place-region-batch/src/main/java/kr/co/yigil/batch/job/DemographicPlaceJobConfig.java new file mode 100644 index 000000000..8e0a1b668 --- /dev/null +++ b/backend/place-region-batch/src/main/java/kr/co/yigil/batch/job/DemographicPlaceJobConfig.java @@ -0,0 +1,111 @@ +package kr.co.yigil.batch.job; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import kr.co.yigil.place.domain.DemographicPlace; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.infrastructure.DemographicPlaceRepository; +import kr.co.yigil.travel.infrastructure.SpotRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.data.RepositoryItemWriter; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.batch.item.data.builder.RepositoryItemWriterBuilder; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class DemographicPlaceJobConfig { + + private final SpotRepository spotRepository; + private final DemographicPlaceRepository demographicPlaceRepository; + + @Bean + public Job demographicPlaceJob( + JobRepository jobRepository, + Step calculateDemographicPlacesStep, + Step clearDemographicPlacesStep + ) { + return new JobBuilder("demographicPlaceJob", jobRepository) + .start(clearDemographicPlacesStep) + .next(calculateDemographicPlacesStep) + .incrementer(new RunIdIncrementer()) + .build(); + } + + @Bean + public Step clearDemographicPlacesStep(JobRepository jobRepository, PlatformTransactionManager platformTransactionManager) { + return new StepBuilder("clearDemographicPlacesStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + demographicPlaceRepository.deleteAll(); + return RepeatStatus.FINISHED; + }, platformTransactionManager) + .build(); + } + + @Bean + public Step calculateDemographicPlacesStep( + JobRepository jobRepository, + PlatformTransactionManager platformTransactionManager, + ItemReader demographicPlaceItemReader, + ItemProcessor demographicPlaceItemProcessor, + ItemWriter demographicPlaceItemWriter + ) { + return new StepBuilder("calculateDemographicPlacesStep", jobRepository) + .chunk(10, platformTransactionManager) + .reader(demographicPlaceItemReader) + .processor(demographicPlaceItemProcessor) + .writer(demographicPlaceItemWriter) + .build(); + } + + @Bean + public ItemReader demographicPlaceItemReader() { + LocalDateTime endDate = LocalDateTime.now(); + LocalDateTime startDate = endDate.minusWeeks(1); + + return new RepositoryItemReaderBuilder() + .name("demographicPlaceItemReader") + .repository(spotRepository) + .methodName("findPlaceReferenceCountGroupByDemographicBetweenDates") + .pageSize(10) + .arguments(List.of(startDate, endDate)) + .sorts(Map.of("referenceCount", Direction.DESC)) + .build(); + } + + @Bean + public ItemProcessor demographicPlaceItemProcessor() { + return item -> { + Place place = (Place) item[0]; + long referenceCount = (long) item[1]; + Ages ages = (Ages) item[2]; + Gender gender = (Gender) item[3]; + return new DemographicPlace(place, referenceCount, ages, gender); + }; + } + + @Bean + public RepositoryItemWriter demographicPlaceItemWriter() { + return new RepositoryItemWriterBuilder() + .repository(demographicPlaceRepository) + .methodName("save") + .build(); + } +} + diff --git a/backend/place-region-batch/src/main/java/kr/co/yigil/batch/job/PopularPlaceJobConfig.java b/backend/place-region-batch/src/main/java/kr/co/yigil/batch/job/PopularPlaceJobConfig.java new file mode 100644 index 000000000..cd242b6d8 --- /dev/null +++ b/backend/place-region-batch/src/main/java/kr/co/yigil/batch/job/PopularPlaceJobConfig.java @@ -0,0 +1,109 @@ +package kr.co.yigil.batch.job; + + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PopularPlace; +import kr.co.yigil.place.infrastructure.PopularPlaceRepository; +import kr.co.yigil.travel.infrastructure.SpotRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.RepositoryItemWriter; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.batch.item.data.builder.RepositoryItemWriterBuilder; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +@Slf4j +public class PopularPlaceJobConfig { + private final SpotRepository spotRepository; + private final PopularPlaceRepository popularPlaceRepository; + @Bean + public Job popularPlaceJob( + JobRepository jobRepository, + Step calculatePopularPlacesStep, + Step clearPopularPlacesStep + ) { + return new JobBuilder("popularPlaceJob", jobRepository) + .start(clearPopularPlacesStep) + .next(calculatePopularPlacesStep) + .incrementer(new RunIdIncrementer()) + .build(); + } + + @Bean + public Step clearPopularPlacesStep(JobRepository jobRepository, PlatformTransactionManager platformTransactionManager) { + return new StepBuilder("clearPopularPlacesStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + popularPlaceRepository.deleteAll(); + return RepeatStatus.FINISHED; + }, platformTransactionManager) + .build(); + } + + @Bean + public Step calculatePopularPlacesStep( + JobRepository jobRepository, + PlatformTransactionManager platformTransactionManager, + ItemReader popularPlaceItemReader, + ItemProcessor popularPlaceItemProcessor, + ItemWriter popularPlaceItemWriter + ) { + return new StepBuilder("calculatePopularPlacesStep", jobRepository) + .chunk(10, platformTransactionManager) + .reader(popularPlaceItemReader) + .processor(popularPlaceItemProcessor) + .writer(popularPlaceItemWriter) + .build(); + } + + @Bean + public RepositoryItemReader popularPlaceItemReader() { + LocalDateTime endDate = LocalDateTime.now(); + LocalDateTime startDate = endDate.minusWeeks(1); + + return new RepositoryItemReaderBuilder() + .name("popularPlaceItemReader") + .repository(spotRepository) + .methodName("findPlaceReferenceCountBetweenDates") + .pageSize(10) + .arguments(List.of(startDate, endDate)) + .sorts(Collections.singletonMap("referenceCount", Direction.DESC)) + .build(); + } + + @Bean + public ItemProcessor popularPlaceItemProcessor() { + return placeCount -> { + Place place = (Place) placeCount[0]; + long referenceCount = (long) placeCount[1]; + return new PopularPlace(place, referenceCount); + }; + } + + @Bean + public RepositoryItemWriter popularPlaceItemWriter() { + return new RepositoryItemWriterBuilder() + .repository(popularPlaceRepository) + .methodName("save") + .build(); + + } +} diff --git a/backend/place-region-batch/src/main/java/kr/co/yigil/batch/job/UpdateRegionJobConfig.java b/backend/place-region-batch/src/main/java/kr/co/yigil/batch/job/UpdateRegionJobConfig.java new file mode 100644 index 000000000..e44e6aff6 --- /dev/null +++ b/backend/place-region-batch/src/main/java/kr/co/yigil/batch/job/UpdateRegionJobConfig.java @@ -0,0 +1,116 @@ +package kr.co.yigil.batch.job; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.infrastructure.PlaceRepository; +import kr.co.yigil.region.domain.Division; +import kr.co.yigil.region.domain.DongDivision; +import kr.co.yigil.region.domain.Region; +import kr.co.yigil.region.infrastructure.DivisionRepository; +import kr.co.yigil.region.infrastructure.DongDivisionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.RepositoryItemWriter; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.batch.item.data.builder.RepositoryItemWriterBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +@Slf4j +public class UpdateRegionJobConfig { + private final PlaceRepository placeRepository; + private final DivisionRepository divisionRepository; + private final DongDivisionRepository dongDivisionRepository; + @Bean + public Job updateRegionJob(JobRepository jobRepository, Step updateRegionStep) { + return new JobBuilder("updateRegionJob", jobRepository) + .start(updateRegionStep) + .incrementer(new RunIdIncrementer()) + .build(); + } + + @Bean + public Step updateRegionStep( + JobRepository jobRepository, + PlatformTransactionManager platformTransactionManager, + ItemReader placeItemReader, + ItemProcessor placeItemProcessor, + ItemWriter placeItemWriter + ) { + return new StepBuilder("updateRegionStep", jobRepository) + .chunk(50, platformTransactionManager) + .reader(placeItemReader) + .processor(placeItemProcessor) + .writer(placeItemWriter) + .build(); + } + + @Bean + public RepositoryItemReader placeItemReader() { + return new RepositoryItemReaderBuilder() + .name("placeItemReader") + .repository(placeRepository) + .methodName("findByRegionIsNull") + .pageSize(50) + .arguments(List.of()) + .sorts(Collections.singletonMap("id", Direction.DESC)) + .build(); + } + + @Bean + public ItemProcessor placeItemProcessor() { + return place -> { + Region region = findRegionForPlace(place); + if (region != null) { + place.updateRegion(region); + return place; + } + return null; + }; + } + + @Bean + public RepositoryItemWriter placeItemWriter() { + return new RepositoryItemWriterBuilder() + .repository(placeRepository) + .methodName("save") + .build(); + } + + + public Region findRegionForPlace(Place place) { + Optional divisionOptional = divisionRepository.findContainingDivision(place.getLocation()); + + if(divisionOptional.isEmpty()) return null; + + Division division = divisionOptional.get(); + + if(division.isSeoul()) { + Optional dongDivisionOptional = dongDivisionRepository.findContainingDivision(place.getLocation()); + + if(dongDivisionOptional.isEmpty()) return null; + + DongDivision dongDivision = dongDivisionOptional.get(); + + return dongDivision.getRegion(); + } + return division.getRegion(); + } + +} diff --git a/backend/place-region-batch/src/main/resources/application.yml b/backend/place-region-batch/src/main/resources/application.yml new file mode 100644 index 000000000..042394e5c --- /dev/null +++ b/backend/place-region-batch/src/main/resources/application.yml @@ -0,0 +1,31 @@ +spring: + batch: + job: + enabled: true + jpa: + database: postgresql + database-platform: org.hibernate.spatial.dialect.postgis.PostgisPG95Dialect + hibernate: + ddl-auto: validate + defer-datasource-initialization: true + + datasource: + driver-class-name: org.postgresql.Driver + url: @MASTER_DB_URL@ + username: @MASTER_DB_USERNAME@ + password: @MASTER_DB_PASSWORD@ + +logging: + level: + root: DEBUG + slack: + webhook-uri: @SLACK_WEBHOOK_URI@ + +decorator: + datasource: + p6spy: + enable-logging: true + +server: + port: @PLACE_REGION_BATCH_PORT@ + diff --git a/backend/place-region-batch/src/main/resources/logback-spring.xml b/backend/place-region-batch/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..77029e9ab --- /dev/null +++ b/backend/place-region-batch/src/main/resources/logback-spring.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + DEBUG + + + ${CONSOLE_LOG_PATTERN} + + + + + ${SLACK_WEBHOOK_URL} + + + ${PID:-} --- [%15.15thread] %-40.40logger{36} %msg%n%n + [CURRENT_MODULE] : YIGIL-BATCH%n + [REQUEST_ID] : %X{REQUEST_ID:-NO REQUEST ID}%n + [REQUEST_METHOD] : %X{REQUEST_METHOD:-NO REQUEST METHOD}%n + [REQUEST_URI] : %X{REQUEST_URI:-NO REQUEST URI}%n + [REQUEST_TIME] : %d{yyyy-MM-dd HH:mm:ss.SSS}%n + [REQUEST_IP] : %X{REQUEST_IP:-NO REQUEST IP}%n + + utf8 + + true + + + + + + ERROR + ACCEPT + DENY + + + + + + + + \ No newline at end of file diff --git a/backend/place-region-batch/src/main/resources/spy.properties b/backend/place-region-batch/src/main/resources/spy.properties new file mode 100644 index 000000000..891d0a343 --- /dev/null +++ b/backend/place-region-batch/src/main/resources/spy.properties @@ -0,0 +1,3 @@ +appender=com.p6spy.engine.spy.appender.Slf4JLogger +logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat +customLogMessageFormat=| %(executionTime) ms | %(sql) \ No newline at end of file diff --git a/backend/settings.gradle b/backend/settings.gradle index fb9f45992..706ddad92 100644 --- a/backend/settings.gradle +++ b/backend/settings.gradle @@ -10,3 +10,8 @@ rootProject.name = 'yigil' include 'yigil-api' include 'support:log' findProject(':support:log')?.name = 'log' +include 'yigil-admin' +include 'support:domain' +findProject(':support:domain')?.name = 'domain' +include 'place-region-batch' + diff --git a/backend/support/Dockerfile b/backend/support/Dockerfile index 116ad8aea..8835b12f1 100644 --- a/backend/support/Dockerfile +++ b/backend/support/Dockerfile @@ -4,6 +4,4 @@ WORKDIR /app COPY log/build/libs/log-0.0.1-SNAPSHOT-plain.jar app.jar -EXPOSE 9090 - CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/backend/support/domain/build.gradle b/backend/support/domain/build.gradle new file mode 100644 index 000000000..ed7e4d08f --- /dev/null +++ b/backend/support/domain/build.gradle @@ -0,0 +1,59 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' + id 'org.jetbrains.kotlin.jvm' +} + +group = 'kr.co.yigil' +version = '0.0.1-SNAPSHOT' + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + querydsl.extendsFrom compileClasspath +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web-services' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'org.postgresql:postgresql' + implementation 'org.postgresql:postgresql' + implementation 'org.hibernate:hibernate-core:6.4.0.Final' + implementation 'org.hibernate:hibernate-spatial:6.4.0.Final' + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + implementation 'org.springframework.boot:spring-boot-starter-security' + +} + +test { + useJUnitPlatform() +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} \ No newline at end of file diff --git a/backend/support/domain/src/main/java/kr/co/yigil/admin/domain/Admin.java b/backend/support/domain/src/main/java/kr/co/yigil/admin/domain/Admin.java new file mode 100644 index 000000000..ae0cb6c62 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/admin/domain/Admin.java @@ -0,0 +1,104 @@ +package kr.co.yigil.admin.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.file.AttachFile; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Admin implements UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 20, unique = true) + private String nickname; + + @ElementCollection(fetch = FetchType.EAGER) + private List roles = new ArrayList<>(); + + @OneToOne(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + @JoinColumn(name = "profile_image_id") + private AttachFile profileImage; + + @Override + public Collection getAuthorities() { + return this.roles.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + @Override + public String getUsername() { + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + public Admin(String email, String password, String nickname, List roles, AttachFile profileImage) { + this.email = email; + this.password = password; + this.nickname = nickname; + this.roles = roles; + this.profileImage = profileImage; + } + + public Admin(String email, String password, String nickname, List roles) { + this.email = email; + this.password = password; + this.nickname = nickname; + this.roles = roles; + } + + public void updateProfileImage(AttachFile profileImage) { + this.profileImage = profileImage; + } + + public void updatePassword(String encodedPassword) { + this.password = encodedPassword; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/admin/domain/AdminSignUp.java b/backend/support/domain/src/main/java/kr/co/yigil/admin/domain/AdminSignUp.java new file mode 100644 index 000000000..59fcd0e77 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/admin/domain/AdminSignUp.java @@ -0,0 +1,40 @@ +package kr.co.yigil.admin.domain; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdminSignUp { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50, unique = true) + private String email; + + @Column(nullable = false, length = 20, unique = true) + private String nickname; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + public AdminSignUp(final String email, final String nickname) { + this.email = email; + this.nickname = nickname; + this.createdAt = LocalDateTime.now(); + } + +} \ No newline at end of file diff --git a/backend/support/domain/src/main/java/kr/co/yigil/admin/infrastructure/AdminRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/admin/infrastructure/AdminRepository.java new file mode 100644 index 000000000..82be9b58b --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/admin/infrastructure/AdminRepository.java @@ -0,0 +1,12 @@ +package kr.co.yigil.admin.infrastructure; + + +import java.util.Optional; +import kr.co.yigil.admin.domain.Admin; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdminRepository extends JpaRepository { + Optional findByEmail(String email); + + boolean existsByEmailOrNickname(String email, String nickname); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/admin/infrastructure/AdminSignUpRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/admin/infrastructure/AdminSignUpRepository.java new file mode 100644 index 000000000..5fb9a96bc --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/admin/infrastructure/AdminSignUpRepository.java @@ -0,0 +1,8 @@ +package kr.co.yigil.admin.infrastructure; + +import kr.co.yigil.admin.domain.AdminSignUp; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdminSignUpRepository extends JpaRepository { + +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/bookmark/domain/Bookmark.java b/backend/support/domain/src/main/java/kr/co/yigil/bookmark/domain/Bookmark.java new file mode 100644 index 000000000..342f9615b --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/bookmark/domain/Bookmark.java @@ -0,0 +1,62 @@ +package kr.co.yigil.bookmark.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import java.util.Objects; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Bookmark { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "place_id") + private Place place; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; + + public Bookmark(final Member member, final Place place) { + this.member = member; + this.place = place; + createdAt = LocalDateTime.now(); + modifiedAt = LocalDateTime.now(); + } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Bookmark bookmark = (Bookmark) o; + return Objects.equals(member, bookmark.member) && + Objects.equals(place, bookmark.place); + } + @Override + public int hashCode() { + return Objects.hash(member, place); + } + +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/bookmark/infrastructure/BookmarkRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/bookmark/infrastructure/BookmarkRepository.java new file mode 100644 index 000000000..646319dc9 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/bookmark/infrastructure/BookmarkRepository.java @@ -0,0 +1,17 @@ +package kr.co.yigil.bookmark.infrastructure; + +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookmarkRepository extends JpaRepository { + + void deleteByMemberAndPlace(Member member, Place place); + + Slice findAllByMember(Member member, Pageable pageable); + + boolean existsByMemberIdAndPlaceId(Long memberId, Long placeId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/Comment.java b/backend/support/domain/src/main/java/kr/co/yigil/comment/domain/Comment.java similarity index 63% rename from backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/Comment.java rename to backend/support/domain/src/main/java/kr/co/yigil/comment/domain/Comment.java index 1b40d521a..a14ea5835 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/Comment.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/comment/domain/Comment.java @@ -10,8 +10,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import java.time.LocalDateTime; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; +import kr.co.yigil.member.Member; +import kr.co.yigil.travel.domain.Travel; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -24,6 +24,7 @@ @SQLDelete(sql = "UPDATE Comment SET is_deleted = true WHERE id = ?") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Comment { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -39,12 +40,9 @@ public class Comment { @JoinColumn(name = "parent_id") private Comment parent; -// @OneToMany(mappedBy = "parent", orphanRemoval = true) -// private final List children = new ArrayList<>(); - @ManyToOne(fetch = LAZY) - @JoinColumn(name = "post_id") - private Post post; + @JoinColumn(name = "travel_id") + private Travel travel; @CreatedDate @Column(updatable = false) @@ -55,35 +53,18 @@ public class Comment { boolean isDeleted; - public Comment(String content, Member member, Post post) { - this.content = content; - this.member = member; - this.post = post; - this.createdAt = LocalDateTime.now(); - this.modifiedAt = LocalDateTime.now(); - } - public Comment(String content, Member member, Post post, Comment parent) { + public Comment(String content, Member member, Travel travel) { this.content = content; this.member = member; - this.post = post; + this.travel = travel; this.createdAt = LocalDateTime.now(); this.modifiedAt = LocalDateTime.now(); - this.parent = parent; } - public Comment(Long id, String content, Member member, Post post) { - this.id = id; + public Comment(String content, Member member, Travel travel, Comment parent) { this.content = content; this.member = member; - this.post = post; - this.createdAt = LocalDateTime.now(); - this.modifiedAt = LocalDateTime.now(); - } - public Comment(Long id, String content, Member member, Post post, Comment parent) { - this.id = id; - this.content = content; - this.member = member; - this.post = post; + this.travel = travel; this.parent = parent; this.createdAt = LocalDateTime.now(); this.modifiedAt = LocalDateTime.now(); @@ -94,4 +75,10 @@ public void updateComment(String content) { this.modifiedAt = LocalDateTime.now(); } -} + public String getContent() { + if(this.isDeleted){ + return "삭제된 댓글입니다."; + } + return this.content; + } +} \ No newline at end of file diff --git a/backend/support/domain/src/main/java/kr/co/yigil/comment/infrastructure/CommentRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/comment/infrastructure/CommentRepository.java new file mode 100644 index 000000000..3466cf021 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/comment/infrastructure/CommentRepository.java @@ -0,0 +1,54 @@ +package kr.co.yigil.comment.infrastructure; + +import java.util.List; +import java.util.Optional; +import kr.co.yigil.comment.domain.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CommentRepository extends JpaRepository { + + @Query("SELECT c FROM Comment c " + + "LEFT JOIN FETCH c.parent " + + "WHERE c.travel.id = :travelId " + + "ORDER BY c.parent.id ASC NULLS FIRST, c.createdAt ASC") + Slice findCommentListByTravelId(@Param("travelId") Long travelId, Pageable pageable); + + @Query("SELECT c FROM Comment c WHERE c.travel.id = :travelId AND c.parent IS NULL " + + "ORDER BY c.createdAt ASC " + ) + Page findParentCommentsByTravelId(@Param("travelId") Long travelId, + Pageable pageable); + + + Slice findAllByTravelIdAndParentIsNull(Long travelId, Pageable pageable); + + + @Query("SELECT c FROM Comment c WHERE c.isDeleted = false AND c.parent.id = :parentId " + + "ORDER BY c.createdAt ASC " + ) + Slice findChildCommentsByParentId(@Param("parentId") Long parentId, Pageable pageable); + + @Query("SELECT COUNT(c) FROM Comment c WHERE c.travel.id = :travelId AND c.isDeleted = false") + int countNonDeletedCommentsByTravelId(@Param("travelId") Long travelId); + + int countAllByTravelIdAndIsDeletedFalse(Long travelId); + + Optional findByIdAndMemberId(Long commentId, Long memberId); + + @Query("SELECT c.travel.id FROM Comment c WHERE c.id = :commentId") + Optional findTravelIdByCommentId(@Param("commentId") Long commentId); + + @Query("SELECT COUNT(c) FROM Comment c WHERE c.parent.id = :parentId AND c.isDeleted = false") + int countByParentId(@Param("parentId") Long parentId); + + Page findAllAsPageImplByTravelIdAndParentIdIsNull(Long travelId, Pageable pageable); + + Page findAllByParentIdAndIsDeletedFalse(Long parentId, PageRequest pageRequest); + List findAllByParentIdAndIsDeletedFalse(Long parentId); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/event/Event.java b/backend/support/domain/src/main/java/kr/co/yigil/event/Event.java new file mode 100644 index 000000000..2d5b1528b --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/event/Event.java @@ -0,0 +1,36 @@ +package kr.co.yigil.event; + +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Inheritance(strategy = InheritanceType.JOINED) +@DiscriminatorColumn(name = "type") +@Getter +public class Event { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String bannerImageUrl; + + private String title; + + private String description; + + private LocalDateTime startAt; + + private LocalDateTime endAt; + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/Favor.java b/backend/support/domain/src/main/java/kr/co/yigil/favor/domain/Favor.java similarity index 78% rename from backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/Favor.java rename to backend/support/domain/src/main/java/kr/co/yigil/favor/domain/Favor.java index 9cf3dd1b0..1ff64d01b 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/Favor.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/favor/domain/Favor.java @@ -6,8 +6,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; +import kr.co.yigil.member.Member; +import kr.co.yigil.travel.domain.Travel; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,10 +26,10 @@ public class Favor { @ManyToOne @JoinColumn(name = "post_id") - private Post post; + private Travel travel; - public Favor(final Member member, final Post post) { + public Favor(final Member member, final Travel travel) { this.member = member; - this.post = post; + this.travel = travel; } } diff --git a/backend/support/domain/src/main/java/kr/co/yigil/favor/domain/repository/FavorRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/favor/domain/repository/FavorRepository.java new file mode 100644 index 000000000..cea55482d --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/favor/domain/repository/FavorRepository.java @@ -0,0 +1,18 @@ +package kr.co.yigil.favor.domain.repository; + +import java.util.Optional; +import kr.co.yigil.favor.domain.Favor; +import kr.co.yigil.member.Member; +import kr.co.yigil.travel.domain.Travel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FavorRepository extends JpaRepository { + int countByTravelId(Long travelId); + + void deleteByMemberAndTravel(Member member, Travel travel); + + boolean existsByMemberIdAndTravelId(Long memberId, Long travelId); + + Optional findFavorByMemberAndTravel(Member member, Travel travel); + +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/file/AttachFile.java b/backend/support/domain/src/main/java/kr/co/yigil/file/AttachFile.java new file mode 100644 index 000000000..e8920cdc6 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/file/AttachFile.java @@ -0,0 +1,46 @@ +package kr.co.yigil.file; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.jetbrains.annotations.NotNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AttachFile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(value = EnumType.STRING) + @NotNull + private FileType fileType; + + @NotNull + private String fileUrl; + + @NotNull + private String originalFileName; + + @NotNull + private Long fileSize; + + public AttachFile(final FileType fileType, final String fileUrl, final String originalFileName, final Long fileSize) { + this.fileType = fileType; + this.fileUrl = fileUrl; + this.originalFileName = originalFileName; + this.fileSize = fileSize; + } + + public String getFileUrl() { + return "http://cdn.yigil.co.kr/" + fileUrl; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/file/AttachFiles.java b/backend/support/domain/src/main/java/kr/co/yigil/file/AttachFiles.java new file mode 100644 index 000000000..1b7efe669 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/file/AttachFiles.java @@ -0,0 +1,67 @@ +package kr.co.yigil.file; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AttachFiles { + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "travel_id") + private List files = new LinkedList<>(); + + public AttachFiles(final List files) { + this.files = files; + } + + public List getFiles() { + return Collections.unmodifiableList(files); + } + + public void updateFiles(List newAttachFiles) { + this.files.clear(); + this.files.addAll(newAttachFiles); + } + + public AttachFile getRepresentativeFile() { + if (files.isEmpty()) { + return null; + } + return files.get(0); + } + + public void addFile(AttachFile file) { + files.add(file); + validateFilesSize(); + } + + private void validateFilesSize() { + if (files.size() > 5) { + throw new IllegalArgumentException("files length must not over 5"); + } + } + + public List getUrls() { + List urls = new ArrayList<>(); + for (AttachFile file : files) { + urls.add(file.getFileUrl()); + } + return urls; + } + + public Optional findByUrl(String url) { + return files.stream() + .filter(file -> file.getFileUrl().equals(url)) + .findFirst(); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/file/FileType.java b/backend/support/domain/src/main/java/kr/co/yigil/file/FileType.java similarity index 100% rename from backend/yigil-api/src/main/java/kr/co/yigil/file/FileType.java rename to backend/support/domain/src/main/java/kr/co/yigil/file/FileType.java diff --git a/backend/support/domain/src/main/java/kr/co/yigil/file/repository/AttachFileRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/file/repository/AttachFileRepository.java new file mode 100644 index 000000000..e93609224 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/file/repository/AttachFileRepository.java @@ -0,0 +1,10 @@ +package kr.co.yigil.file.repository; + +import java.util.Optional; +import kr.co.yigil.file.AttachFile; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttachFileRepository extends JpaRepository { + + Optional findAttachFileByFileUrl(String url); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/dto/FollowCountDto.java b/backend/support/domain/src/main/java/kr/co/yigil/follow/FollowCountDto.java similarity index 92% rename from backend/yigil-api/src/main/java/kr/co/yigil/follow/dto/FollowCountDto.java rename to backend/support/domain/src/main/java/kr/co/yigil/follow/FollowCountDto.java index 10c9ea0f9..bad6666e7 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/dto/FollowCountDto.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/follow/FollowCountDto.java @@ -1,4 +1,4 @@ -package kr.co.yigil.follow.dto; +package kr.co.yigil.follow; import lombok.Data; import lombok.NoArgsConstructor; diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/Follow.java b/backend/support/domain/src/main/java/kr/co/yigil/follow/domain/Follow.java similarity index 63% rename from backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/Follow.java rename to backend/support/domain/src/main/java/kr/co/yigil/follow/domain/Follow.java index b3625748f..3de61cdbc 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/Follow.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/follow/domain/Follow.java @@ -6,7 +6,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import kr.co.yigil.member.domain.Member; +import java.util.Objects; +import kr.co.yigil.member.Member; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -31,4 +32,19 @@ public Follow(final Member follower, final Member following) { this.follower = follower; this.following = following; } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Follow follow = (Follow) o; + return Objects.equals(follower, follow.follower) && + Objects.equals(following, follow.following); + } + + @Override + public int hashCode() { + return Objects.hash(follower, following); + } } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowCount.java b/backend/support/domain/src/main/java/kr/co/yigil/follow/domain/FollowCount.java similarity index 52% rename from backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowCount.java rename to backend/support/domain/src/main/java/kr/co/yigil/follow/domain/FollowCount.java index 47996ccec..cd0a26f6f 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowCount.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/follow/domain/FollowCount.java @@ -1,5 +1,7 @@ package kr.co.yigil.follow.domain; +import java.io.Serializable; +import java.util.Objects; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.data.annotation.Id; @@ -9,7 +11,7 @@ @Getter @AllArgsConstructor @RedisHash("followCount") -public class FollowCount { +public class FollowCount implements Serializable { @Id private Long memberId; @@ -34,4 +36,19 @@ public void decrementFollowingCount() { followingCount--; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FollowCount that = (FollowCount) o; + return followerCount == that.followerCount && + followingCount == that.followingCount && + Objects.equals(memberId, that.memberId); + } + + @Override + public int hashCode() { + return Objects.hash(memberId, followerCount, followingCount); + } } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/repository/FollowCountRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/follow/infrastructure/FollowCountRepository.java similarity index 80% rename from backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/repository/FollowCountRepository.java rename to backend/support/domain/src/main/java/kr/co/yigil/follow/infrastructure/FollowCountRepository.java index 839cdeef5..fa8b55932 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/repository/FollowCountRepository.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/follow/infrastructure/FollowCountRepository.java @@ -1,4 +1,4 @@ -package kr.co.yigil.follow.domain.repository; +package kr.co.yigil.follow.infrastructure; import kr.co.yigil.follow.domain.FollowCount; import org.springframework.data.repository.CrudRepository; diff --git a/backend/support/domain/src/main/java/kr/co/yigil/follow/infrastructure/FollowRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/follow/infrastructure/FollowRepository.java new file mode 100644 index 000000000..03eb8d518 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/follow/infrastructure/FollowRepository.java @@ -0,0 +1,32 @@ +package kr.co.yigil.follow.infrastructure; + +import kr.co.yigil.follow.domain.Follow; +import kr.co.yigil.follow.FollowCountDto; +import kr.co.yigil.member.Member; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface FollowRepository extends JpaRepository { + public boolean existsByFollowerIdAndFollowingId(Long followerId, Long followingId); + + public Slice findAllByFollowing(Member member); + public Slice findAllByFollowingId(Long memberId, Pageable pageable); + public Slice findAllByFollowerId(Long memberId, Pageable pageable); + + public Slice findAllByFollower(Member member, Pageable pageable); + + @Query("SELECT new kr.co.yigil.follow.FollowCountDto(" + + " (SELECT COUNT(f1) FROM Follow f1 WHERE f1.following = :member), " + + " (SELECT COUNT(f2) FROM Follow f2 WHERE f2.follower = :member))") + FollowCountDto getFollowCounts(@Param("member") Member member); + + @Query("SELECT new kr.co.yigil.follow.FollowCountDto(" + + " (SELECT COUNT(f1) FROM Follow f1 WHERE f1.following.id = :memberId), " + + " (SELECT COUNT(f2) FROM Follow f2 WHERE f2.follower.id = :memberId))") + FollowCountDto getFollowCounts(@Param("memberId") Long memberId); + + public void deleteByFollowerAndFollowing(Member Follower, Member Following); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/global/Selected.java b/backend/support/domain/src/main/java/kr/co/yigil/global/Selected.java new file mode 100644 index 000000000..d393977f7 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/global/Selected.java @@ -0,0 +1,17 @@ +package kr.co.yigil.global; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum Selected { + ALL("all"), + PUBLIC("public"), + PRIVATE("private"); + + private String value; + Selected(String value) { + this.value = value; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/global/SortBy.java b/backend/support/domain/src/main/java/kr/co/yigil/global/SortBy.java new file mode 100644 index 000000000..ed46ba844 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/global/SortBy.java @@ -0,0 +1,19 @@ +package kr.co.yigil.global; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SortBy { + CREATED_AT("createdAt"), + RATE("rate"), + ID("id"), + LATEST_UPLOADED_TIME("latestUploadedTime"); + + private String value; + SortBy(String value) { + this.value = value; + } + +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/global/SortOrder.java b/backend/support/domain/src/main/java/kr/co/yigil/global/SortOrder.java new file mode 100644 index 000000000..396ac3df8 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/global/SortOrder.java @@ -0,0 +1,13 @@ +package kr.co.yigil.global; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SortOrder { + ASC("asc"), + DESC("desc"); + + private final String value; +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/global/utils/StringToSelectedConverter.java b/backend/support/domain/src/main/java/kr/co/yigil/global/utils/StringToSelectedConverter.java new file mode 100644 index 000000000..fdbd87360 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/global/utils/StringToSelectedConverter.java @@ -0,0 +1,14 @@ +package kr.co.yigil.global.utils; + +import kr.co.yigil.global.Selected; +import org.jetbrains.annotations.NotNull; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class StringToSelectedConverter implements Converter { + @Override + public Selected convert(@NotNull String source) { + return Selected.valueOf(source.toUpperCase()); + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/global/utils/StringToSortByConverter.java b/backend/support/domain/src/main/java/kr/co/yigil/global/utils/StringToSortByConverter.java new file mode 100644 index 000000000..a53741e0d --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/global/utils/StringToSortByConverter.java @@ -0,0 +1,13 @@ +package kr.co.yigil.global.utils; + +import kr.co.yigil.global.SortBy; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class StringToSortByConverter implements Converter { + @Override + public SortBy convert(String source) { + return SortBy.valueOf(source.toUpperCase()); + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/global/utils/StringToSortOrderConverter.java b/backend/support/domain/src/main/java/kr/co/yigil/global/utils/StringToSortOrderConverter.java new file mode 100644 index 000000000..c3f9ad576 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/global/utils/StringToSortOrderConverter.java @@ -0,0 +1,13 @@ +package kr.co.yigil.global.utils; + +import kr.co.yigil.global.SortOrder; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class StringToSortOrderConverter implements Converter { + @Override + public SortOrder convert(String source) { + return SortOrder.valueOf(source.toUpperCase()); + } +} \ No newline at end of file diff --git a/backend/support/domain/src/main/java/kr/co/yigil/member/Ages.java b/backend/support/domain/src/main/java/kr/co/yigil/member/Ages.java new file mode 100644 index 000000000..4acfa6cee --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/member/Ages.java @@ -0,0 +1,29 @@ +package kr.co.yigil.member; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Ages{ + NONE("없음"), + UNDER_TEENAGERS("10대 이하"), + TEENAGERS("10대"), + TWENTIES("20대"), + THIRTIES("30대"), + FORTIES("40대"), + FIFTIES("50대"), + OVER_SIXTIES("60대 이상"); + + private final String viewName; + @JsonCreator + public static Ages from(String s) { + for (Ages ages : Ages.values()) { + if (ages.getViewName().equals(s)) { + return ages; + } + } + return Ages.NONE; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/member/Gender.java b/backend/support/domain/src/main/java/kr/co/yigil/member/Gender.java new file mode 100644 index 000000000..deffd191c --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/member/Gender.java @@ -0,0 +1,24 @@ +package kr.co.yigil.member; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Gender { + MALE("남성"), + FEMALE("여성"), + NONE("없음"); + + private final String viewName; + @JsonCreator + public static Gender from(String s) { + for (Gender gender : Gender.values()) { + if (gender.getViewName().equals(s)) { + return gender; + } + } + return Gender.NONE; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/member/Member.java b/backend/support/domain/src/main/java/kr/co/yigil/member/Member.java new file mode 100644 index 000000000..d4618e72c --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/member/Member.java @@ -0,0 +1,169 @@ +package kr.co.yigil.member; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.region.domain.MemberRegion; +import kr.co.yigil.region.domain.Region; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = {"social_login_id", "social_login_type"}) +}) +@SQLDelete(sql = "UPDATE member SET status = 'WITHDRAW' WHERE id = ?") +@Where(clause = "status = 'ACTIVE'") +public class Member { + + private static final String DEFAULT_PROFILE_CDN = "http://cdn.yigil.co.kr/"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50, unique = true) + private String email; + + @Column(nullable = false, length = 30) + private String socialLoginId; + + @Column(nullable = false, length = 20) + private String nickname; + + @Column(columnDefinition = "TEXT") + private String profileImageUrl; + + @Enumerated(value = EnumType.STRING) + private MemberStatus status; + + @Enumerated(value = EnumType.STRING) + private SocialLoginType socialLoginType; + + @Enumerated(value = EnumType.STRING) + private Gender gender = Gender.NONE; + + @Enumerated(value = EnumType.STRING) + private Ages ages = Ages.NONE; + + @OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true) + private final List favoriteRegions = new ArrayList<>(); + + @CreatedDate + @Column(updatable = false) + private LocalDateTime joinedAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; + + public Member(final String email, final String socialLoginId, final String nickname, + final String profileImageUrl, final String socialLoginTypeString) { + this.email = email; + this.socialLoginId = socialLoginId; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.status = MemberStatus.ACTIVE; + this.socialLoginType = SocialLoginType.valueOf(socialLoginTypeString.toUpperCase()); + this.joinedAt = LocalDateTime.now(); + this.modifiedAt = LocalDateTime.now(); + } + + public Member(final String email, final String socialLoginId, final String nickname, + final String profileImageUrl, final SocialLoginType socialLoginType) { + this.email = email; + this.socialLoginId = socialLoginId; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.status = MemberStatus.ACTIVE; + this.socialLoginType = socialLoginType; + this.joinedAt = LocalDateTime.now(); + this.modifiedAt = LocalDateTime.now(); + } + + public Member(final Long id, final String email, final String socialLoginId, + final String nickname, final String profileImageUrl, + final SocialLoginType socialLoginType) { + this.id = id; + this.email = email; + this.socialLoginId = socialLoginId; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.status = MemberStatus.ACTIVE; + this.socialLoginType = socialLoginType; + this.joinedAt = LocalDateTime.now(); + this.modifiedAt = LocalDateTime.now(); + } + + public Member(final Long id, final String email, final String socialLoginId, final String nickname, + final String profileImageUrl, final SocialLoginType socialLoginType, final Ages ages, final Gender gender) { + this.id = id; + this.email = email; + this.socialLoginId = socialLoginId; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.status = MemberStatus.ACTIVE; + this.socialLoginType = socialLoginType; + this.joinedAt = LocalDateTime.now(); + this.modifiedAt = LocalDateTime.now(); + this.ages = ages; + this.gender = gender; + } + + public void updateMemberInfo(final String nickname, final String age, final String gender, + final AttachFile profileImageFile, final List favoriteRegions) { + this.nickname = nickname; + this.ages = Ages.from(age); + this.gender = Gender.from(gender); + if (profileImageFile != null) { + this.profileImageUrl = profileImageFile.getFileUrl(); + } + this.favoriteRegions.clear(); + this.favoriteRegions.addAll(favoriteRegions); + } + + public String getProfileImageUrl() { + if (profileImageUrl == null) { + return null; + } + if (profileImageUrl.startsWith("http://") || profileImageUrl.startsWith("https://")) { + return profileImageUrl; + } else { + return DEFAULT_PROFILE_CDN + profileImageUrl; + } + } + + public List getFavoriteRegionIds() { + return favoriteRegions.stream().map( + memberRegion -> memberRegion.getRegion().getId() + ).toList(); + } + + public boolean isFavoriteRegion(Region region) { + return favoriteRegions.stream() + .anyMatch(memberRegion -> memberRegion.getRegion().equals(region)); + } + + public List getFavoriteRegions() { + return favoriteRegions.stream().map(MemberRegion::getRegion).toList(); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberStatus.java b/backend/support/domain/src/main/java/kr/co/yigil/member/MemberStatus.java similarity index 66% rename from backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberStatus.java rename to backend/support/domain/src/main/java/kr/co/yigil/member/MemberStatus.java index 02879162b..43f17ecb2 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberStatus.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/member/MemberStatus.java @@ -1,4 +1,4 @@ -package kr.co.yigil.member.domain; +package kr.co.yigil.member; public enum MemberStatus { ACTIVE, diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/SocialLoginType.java b/backend/support/domain/src/main/java/kr/co/yigil/member/SocialLoginType.java similarity index 69% rename from backend/yigil-api/src/main/java/kr/co/yigil/member/domain/SocialLoginType.java rename to backend/support/domain/src/main/java/kr/co/yigil/member/SocialLoginType.java index 6b704432a..79e914095 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/SocialLoginType.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/member/SocialLoginType.java @@ -1,4 +1,4 @@ -package kr.co.yigil.member.domain; +package kr.co.yigil.member; public enum SocialLoginType { KAKAO, diff --git a/backend/support/domain/src/main/java/kr/co/yigil/member/repository/MemberRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/member/repository/MemberRepository.java new file mode 100644 index 000000000..f6a241635 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/member/repository/MemberRepository.java @@ -0,0 +1,35 @@ +package kr.co.yigil.member.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; + +public interface MemberRepository extends JpaRepository { + + Optional findMemberBySocialLoginIdAndSocialLoginType(String socialLoginId, SocialLoginType type); + + Optional findMemberByEmailAndSocialLoginType(String email, SocialLoginType type); + + @Query(value = "SELECT m.* FROM Member m WHERE m.id = :memberId", nativeQuery = true) + Optional findByIdRegardlessOfStatus(Long memberId); + + @Query(value = "SELECT m.* FROM Member m ORDER BY m.joined_at DESC", nativeQuery = true) + Page findAllMembersRegardlessOfStatus(Pageable pageable); + + @Modifying + @Query("UPDATE Member m SET m.status = 'BANNED' WHERE m.id = :memberId") + void banMemberById(@Param("memberId") Long memberId); + @Modifying + @Query(value = "UPDATE member SET status = 'ACTIVE' WHERE id = :memberId", nativeQuery = true) + void unbanMemberById(@Param("memberId") Long memberId); + + boolean existsByNickname(String nickname); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/notice/domain/Notice.java b/backend/support/domain/src/main/java/kr/co/yigil/notice/domain/Notice.java new file mode 100644 index 000000000..ecb6ffd44 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/notice/domain/Notice.java @@ -0,0 +1,64 @@ +package kr.co.yigil.notice.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import java.util.Optional; +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.file.AttachFile; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notice { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @Column(columnDefinition = "TEXT") + private String content; + + @JoinColumn(name = "author_id") + @ManyToOne(fetch = FetchType.LAZY) + private Admin author; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; + public Notice (Admin author, String title, String content){ + this.author = author; + this.title = title; + this.content = content; + createdAt = LocalDateTime.now(); + modifiedAt = LocalDateTime.now(); + } + + public void updateNotice(String title, String content){ + this.title = title; + this.content = content; + modifiedAt = LocalDateTime.now(); + } + + public String getAuthorProfileImage(){ + return Optional.ofNullable(author.getProfileImage()) + .map(AttachFile::getFileUrl) + .orElse(""); + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/notice/infrastructure/NoticeRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/notice/infrastructure/NoticeRepository.java new file mode 100644 index 000000000..323600a4b --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/notice/infrastructure/NoticeRepository.java @@ -0,0 +1,8 @@ +package kr.co.yigil.notice.infrastructure; + +import kr.co.yigil.notice.domain.Notice; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NoticeRepository extends JpaRepository { + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/Notification.java b/backend/support/domain/src/main/java/kr/co/yigil/notification/domain/Notification.java similarity index 87% rename from backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/Notification.java rename to backend/support/domain/src/main/java/kr/co/yigil/notification/domain/Notification.java index f6fb3d8a3..0f2f7ef7c 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/Notification.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/notification/domain/Notification.java @@ -7,11 +7,11 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import kr.co.yigil.member.domain.Member; +import java.time.LocalDateTime; +import kr.co.yigil.member.Member; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import org.joda.time.LocalDateTime; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; @@ -33,10 +33,11 @@ public class Notification { private boolean isRead; @CreatedDate - @Column(updatable = false) + @Column(updatable = false, columnDefinition = "TIMESTAMP") private LocalDateTime createdAt; @LastModifiedDate + @Column(columnDefinition = "TIMESTAMP") private LocalDateTime modifiedAt; public Notification(final Member member, final String message) { diff --git a/backend/support/domain/src/main/java/kr/co/yigil/notification/infrastructure/NotificationRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/notification/infrastructure/NotificationRepository.java new file mode 100644 index 000000000..f80d1a052 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/notification/infrastructure/NotificationRepository.java @@ -0,0 +1,11 @@ +package kr.co.yigil.notification.infrastructure; + +import kr.co.yigil.notification.domain.Notification; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { + + Slice findAllByMemberId(Long memberId, Pageable pageable); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/place/domain/DemographicPlace.java b/backend/support/domain/src/main/java/kr/co/yigil/place/domain/DemographicPlace.java new file mode 100644 index 000000000..7a5255e50 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/place/domain/DemographicPlace.java @@ -0,0 +1,44 @@ +package kr.co.yigil.place.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DemographicPlace { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "place_id") + private Place place; + + private Long referenceCount; + + @Enumerated(value = EnumType.STRING) + private Ages ages = Ages.NONE; + + @Enumerated(value = EnumType.STRING) + private Gender gender = Gender.NONE; + + public DemographicPlace(Place place, Long referenceCount, Ages ages, Gender gender) { + this.place = place; + this.referenceCount = referenceCount; + this.ages = ages; + this.gender = gender; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/place/domain/Place.java b/backend/support/domain/src/main/java/kr/co/yigil/place/domain/Place.java new file mode 100644 index 000000000..009a07fa6 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/place/domain/Place.java @@ -0,0 +1,97 @@ +package kr.co.yigil.place.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.region.domain.Region; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.locationtech.jts.geom.Point; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = {"name", "address"})} +) +public class Place { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private String address; + + private LocalDateTime latestUploadedTime; + + @Column(columnDefinition = "double precision default 0") + private double rate; + + @Column(columnDefinition = "geometry(Point,4326)") + private Point location; + + @OneToOne(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + @JoinColumn(name = "map_static_image_file_id") + private AttachFile mapStaticImageFile; + + @OneToOne(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + @JoinColumn(name = "image_file_id") + private AttachFile imageFile; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_id") + private Region region; + + public Place(final String name, final String address, final double rate, + final Point location, final AttachFile imageFile, final AttachFile mapStaticImageFile, final LocalDateTime latestUploadedTime) { + this.name = name; + this.address = address; + this.rate = rate; + this.location = location; + this.imageFile = imageFile; + this.mapStaticImageFile = mapStaticImageFile; + this.latestUploadedTime = latestUploadedTime; + } + + public Place(Long id, final String name, final String address, final double rate, + final Point location, final AttachFile imageFile, final AttachFile mapStaticImageFile, final LocalDateTime latestUploadedTime) { + this.id = id; + this.name = name; + this.address = address; + this.rate = rate; + this.location = location; + this.imageFile = imageFile; + this.mapStaticImageFile = mapStaticImageFile; + this.latestUploadedTime = latestUploadedTime; + } + + public String getImageFileUrl() { + return imageFile.getFileUrl(); + } + + public String getMapStaticImageFileUrl() { + return mapStaticImageFile.getFileUrl(); + } + + public void updateRegion(Region region) { + this.region = region; + } + + public void updateLatestUploadedTime(LocalDateTime latestUploadedTime) { + this.latestUploadedTime = latestUploadedTime; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/place/domain/PopularPlace.java b/backend/support/domain/src/main/java/kr/co/yigil/place/domain/PopularPlace.java new file mode 100644 index 000000000..756fe894e --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/place/domain/PopularPlace.java @@ -0,0 +1,32 @@ +package kr.co.yigil.place.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PopularPlace { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "place_id") + private Place place; + + private Long referenceCount; + + public PopularPlace(Place place, Long referenceCount) { + this.place = place; + this.referenceCount = referenceCount; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/place/infrastructure/DemographicPlaceRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/place/infrastructure/DemographicPlaceRepository.java new file mode 100644 index 000000000..909435fea --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/place/infrastructure/DemographicPlaceRepository.java @@ -0,0 +1,13 @@ +package kr.co.yigil.place.infrastructure; + +import java.util.List; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import kr.co.yigil.place.domain.DemographicPlace; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DemographicPlaceRepository extends JpaRepository { + List findTop5ByAgesAndGenderOrderByReferenceCountDesc(Ages ages, Gender gender); + + List findTop20ByAgesAndGenderOrderByReferenceCountDesc(Ages ages, Gender gender); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/place/infrastructure/PlaceRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/place/infrastructure/PlaceRepository.java new file mode 100644 index 000000000..42143d375 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/place/infrastructure/PlaceRepository.java @@ -0,0 +1,27 @@ +package kr.co.yigil.place.infrastructure; + +import java.util.List; +import java.util.Optional; +import kr.co.yigil.place.domain.Place; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface PlaceRepository extends JpaRepository { + List findTop5ByOrderByIdAsc(); + Optional findByNameAndAddress(String name, String address); + List findTop5ByRegionIdOrderByIdDesc(Long regionId); + List findTop20ByRegionIdOrderByIdDesc(Long regionId); + Slice findByRegionIsNull(Pageable pageable); + List findTop10ByNameStartingWith(String name); + @Query(value = "SELECT p.* FROM Place p WHERE ST_Within(p.location, ST_MakeEnvelope(:minX, :minY, :maxX, :maxY, 4326))", nativeQuery = true) + Page findWithinCoordinates(double minX, double minY, double maxX, double maxY, Pageable pageable); + + @Query("SELECT p FROM Place p WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(p.address) LIKE LOWER(CONCAT('%', :keyword, '%'))") + Slice findByNameOrAddressContainingIgnoreCase(String keyword, Pageable pageable); + } + + diff --git a/backend/support/domain/src/main/java/kr/co/yigil/place/infrastructure/PopularPlaceRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/place/infrastructure/PopularPlaceRepository.java new file mode 100644 index 000000000..8a21faea7 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/place/infrastructure/PopularPlaceRepository.java @@ -0,0 +1,13 @@ +package kr.co.yigil.place.infrastructure; + +import java.util.List; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PopularPlace; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PopularPlaceRepository extends JpaRepository { + + List findTop5ByOrderByReferenceCountDesc(); + + List findTop20ByOrderByReferenceCountDesc(); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/region/domain/Division.java b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/Division.java new file mode 100644 index 000000000..f0e916ffd --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/Division.java @@ -0,0 +1,38 @@ +package kr.co.yigil.region.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "division") +public class Division { + + @Id + private int gid; + + @Column(columnDefinition = "geometry(MultiPolygon,5186)", name = "geom") + private MultiPolygon geometry; + + @Column(name = "city") + private String city; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_id") + private Region region; + + public boolean isSeoul() { + return city.equals("서울시"); + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/region/domain/DongDivision.java b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/DongDivision.java new file mode 100644 index 000000000..8668f12dd --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/DongDivision.java @@ -0,0 +1,40 @@ +package kr.co.yigil.region.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "dong_division") +public class DongDivision { + @Id + private int gid; + + @Column + private String baseDate; + + @Column(name = "adm_nm") + private String name; + + @Column(name = "adm_cd") + private String divisionCode; + + @Column(columnDefinition = "geometry(MultiPolygon,5186)", name = "geom") + private MultiPolygon geometry; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_id") + private Region region; + +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/region/domain/MemberRegion.java b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/MemberRegion.java new file mode 100644 index 000000000..871cd3b50 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/MemberRegion.java @@ -0,0 +1,35 @@ +package kr.co.yigil.region.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import kr.co.yigil.member.Member; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class MemberRegion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_id") + private Region region; + + public MemberRegion(Member member, Region region) { + this.member = member; + this.region = region; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/region/domain/Region.java b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/Region.java new file mode 100644 index 000000000..d4a12e26a --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/Region.java @@ -0,0 +1,27 @@ +package kr.co.yigil.region.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class Region { + @Id + private Long id; + + @Column + private String name1; + + @Column + private String name2; + + @ManyToOne + @JoinColumn(name = "category_id") + private RegionCategory category; +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/region/domain/RegionCategory.java b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/RegionCategory.java new file mode 100644 index 000000000..0774a5515 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/region/domain/RegionCategory.java @@ -0,0 +1,25 @@ +package kr.co.yigil.region.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class RegionCategory { + + @Id + private Long id; + + @Column + private String name; + + @OneToMany(mappedBy = "category") + private List regions; +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/DivisionRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/DivisionRepository.java new file mode 100644 index 000000000..efd4885d9 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/DivisionRepository.java @@ -0,0 +1,13 @@ +package kr.co.yigil.region.infrastructure; + +import java.util.Optional; +import kr.co.yigil.region.domain.Division; +import org.locationtech.jts.geom.Point; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DivisionRepository extends JpaRepository { + @Query(value = "SELECT * FROM division d WHERE ST_Contains(d.geom, ST_Transform(ST_SetSRID(CAST(:location AS geometry), 4326), 5186)) LIMIT 1", nativeQuery = true) + Optional findContainingDivision(@Param("location") Point location); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/DongDivisionRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/DongDivisionRepository.java new file mode 100644 index 000000000..34b485234 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/DongDivisionRepository.java @@ -0,0 +1,14 @@ +package kr.co.yigil.region.infrastructure; + +import java.util.Optional; +import kr.co.yigil.region.domain.Division; +import kr.co.yigil.region.domain.DongDivision; +import org.locationtech.jts.geom.Point; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DongDivisionRepository extends JpaRepository { + @Query(value = "SELECT * FROM dong_division d WHERE ST_Contains(d.geom, ST_Transform(ST_SetSRID(CAST(:location AS geometry), 4326), 5186)) LIMIT 1", nativeQuery = true) + Optional findContainingDivision(@Param("location") Point location); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/RegionCategoryRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/RegionCategoryRepository.java new file mode 100644 index 000000000..3e07ef7da --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/RegionCategoryRepository.java @@ -0,0 +1,8 @@ +package kr.co.yigil.region.infrastructure; + +import kr.co.yigil.region.domain.RegionCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionCategoryRepository extends JpaRepository { + +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/RegionRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/RegionRepository.java new file mode 100644 index 000000000..dffcdbe12 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/region/infrastructure/RegionRepository.java @@ -0,0 +1,10 @@ +package kr.co.yigil.region.infrastructure; + +import kr.co.yigil.region.domain.Region; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RegionRepository extends JpaRepository{ + boolean existsById(Long regionId); +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/travel/domain/Course.java b/backend/support/domain/src/main/java/kr/co/yigil/travel/domain/Course.java new file mode 100644 index 000000000..1071ee120 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/travel/domain/Course.java @@ -0,0 +1,69 @@ +package kr.co.yigil.travel.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.OrderColumn; +import java.util.List; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.member.Member; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.locationtech.jts.geom.LineString; + +@Entity +@Getter +@DiscriminatorValue("COURSE") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Course extends Travel { + + @Column(columnDefinition = "geometry(LineString,4326)") + private LineString path; + + @OneToMany(cascade = CascadeType.PERSIST) + @JoinColumn(name = "course_id") + @OrderColumn(name = "spot_order") + private List spots; + + private int representativeSpotOrder; + + @OneToOne(cascade = CascadeType.PERSIST) + @JoinColumn(name = "attach_file_id") + private AttachFile mapStaticImageFile; + + + public Course(final Member member, final String title, final String description, + final double rate, final LineString path, final boolean isPrivate, final List spots, + final int representativeSpotOrder, final AttachFile mapStaticImageFile) { + super(member, title, description, rate, isPrivate); + this.path = path; + this.spots = spots; + this.representativeSpotOrder = representativeSpotOrder; + this.mapStaticImageFile = mapStaticImageFile; + } + + public Course(final Long id, final Member member, final String title, final String description, + final double rate, final LineString path, final boolean isPrivate, final List spots, + final int representativeSpotOrder, final AttachFile mapStaticImageFile) { + super(id, member, title, description, rate, isPrivate); + this.path = path; + this.spots = spots; + this.representativeSpotOrder = representativeSpotOrder; + this.mapStaticImageFile = mapStaticImageFile; + } + + public void updateCourse(String description, double rate, List spots) { + updateTravel(description, rate); + this.spots.clear(); + this.spots.addAll(spots); + } + + public String getMapStaticImageFileUrl() { + return mapStaticImageFile.getFileUrl(); + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/travel/domain/Spot.java b/backend/support/domain/src/main/java/kr/co/yigil/travel/domain/Spot.java new file mode 100644 index 000000000..fec6ab112 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/travel/domain/Spot.java @@ -0,0 +1,73 @@ +package kr.co.yigil.travel.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.util.List; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.AttachFiles; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; +import org.locationtech.jts.geom.Point; + +@Entity +@Getter +@DiscriminatorValue("SPOT") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Spot extends Travel { + + @Column(columnDefinition = "geometry(Point,4326)") + private Point location; + + @Setter + private boolean isInCourse; + + @Embedded + private AttachFiles attachFiles; + + @NotFound(action = NotFoundAction.IGNORE) + @ManyToOne(cascade = CascadeType.PERSIST) + @JoinColumn(name = "place_id") + private Place place; + + public Spot(final Long id, final Member member, final Point location, final boolean isInCourse, + final String title, final String description, final AttachFiles attachFiles, + final Place place, final double rate) { + super(id, member, title, description, rate, false); + this.location = location; + this.isInCourse = isInCourse; + this.attachFiles = attachFiles; + this.place = place; + } + + + public Spot(final Member member, final Point location, final boolean isInCourse, final String title, + final String description, final AttachFiles attachFiles, final Place place, + final double rate) { + super(member, title, description, rate, false); + this.location = location; + this.isInCourse = isInCourse; + this.attachFiles = attachFiles; + this.place = place; + } + + public void updateSpot(double rate, final String description, final List attachFiles) { + updateTravel(description, rate); + this.attachFiles.updateFiles(attachFiles); + } + + public void changeInCourse() { + this.isInCourse = true; + } + public void changeOutOfCourse() { this.isInCourse = false; } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/travel/domain/Travel.java b/backend/support/domain/src/main/java/kr/co/yigil/travel/domain/Travel.java new file mode 100644 index 000000000..7e1fdb967 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/travel/domain/Travel.java @@ -0,0 +1,96 @@ +package kr.co.yigil.travel.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import kr.co.yigil.member.Member; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + + +@Entity +@Inheritance(strategy = InheritanceType.JOINED) +@DiscriminatorColumn(name = "type") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE Travel SET is_deleted = true WHERE id = ?") +@Where(clause = "is_deleted = false") +public class Travel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @Column(nullable = false, length = 20) + private String title; + + @Column(columnDefinition = "TEXT") + private String description; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; + + private double rate; + + boolean isDeleted; + + boolean isPrivate; + + + protected Travel(Member member, String title, String description, double rate, boolean isPrivate) { + this.member = member; + this.title = title; + this.description = description; + this.rate = rate; + createdAt = LocalDateTime.now(); + modifiedAt = LocalDateTime.now(); + this.isPrivate = isPrivate; + } + protected Travel(Member member, String title, String description, double rate) { + this(member, title, description, rate, false); + } + + public Travel(final Long id,Member member, String title, String description, double rate, boolean isPrivate) { + this.id = id; + this.member = member; + this.title = title; + this.description = description; + this.rate = rate; + createdAt = LocalDateTime.now(); + modifiedAt = LocalDateTime.now(); + this.isPrivate = isPrivate; + } + protected Travel(Long id, Member member, String title, String description, double rate) { + this(id, member, title, description, rate, false); + } + + public void changeOnPublic() { this.isPrivate = false; } + + public void changeOnPrivate() { this.isPrivate = true; } + + public void updateTravel(String description, double rate) { + this.description = description; + this.rate = rate; + } +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/CourseQueryDslRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/CourseQueryDslRepository.java new file mode 100644 index 000000000..ac509f1ae --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/CourseQueryDslRepository.java @@ -0,0 +1,88 @@ +package kr.co.yigil.travel.infrastructure; + +import static org.springframework.util.ObjectUtils.isEmpty; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.ArrayList; +import java.util.List; +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.QCourse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class CourseQueryDslRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Page findAllByMemberIdAndIsPrivate(Long memberId, Selected visibility, + Pageable pageable) { + QCourse course = QCourse.course; + BooleanBuilder builder = new BooleanBuilder(); + + if (memberId != null) { + builder.and(course.member.id.eq(memberId)); + } + + switch (visibility) { + case Selected.PRIVATE -> builder.and(course.isPrivate.eq(true)); + case Selected.PUBLIC -> builder.and(course.isPrivate.eq(false)); + case Selected.ALL -> { + }// 'all'일 때는 아무런 필터링을 하지 않습니다. + default -> + throw new IllegalArgumentException("Invalid visibility value: " + visibility); + } + + Predicate predicate = builder.getValue(); + List> orderSpecifiers = getOrderSpecifiers(pageable.getSort()); + + List courses = jpaQueryFactory.select(course) + .from(course) + .where(predicate) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + long total = jpaQueryFactory.selectFrom(course) + .where(predicate) + .fetchCount(); + + return new PageImpl<>(courses, pageable, total); + } + + private List> getOrderSpecifiers(Sort sort) { + + List> specifiers = new ArrayList<>(); + + if(!isEmpty(sort)){ + for(Sort.Order order:sort){ + if(order.getProperty().equals("rate") || order.getProperty().equals("createdAt")) + specifiers.add(getSortedColumn(order.getDirection().isAscending() ? Order.ASC : Order.DESC, QCourse.course, order.getProperty())); + OrderSpecifier specifier = getSortedColumn(order.getDirection().isAscending() ? Order.ASC : Order.DESC, QCourse.course, order.getProperty()); + specifiers.add(specifier); + } + } + + return specifiers; + } + + public static OrderSpecifier getSortedColumn(Order order, Path parent, String fieldName) { + Path fieldPath = Expressions.path(Object.class, parent, fieldName); + + return new OrderSpecifier(order, fieldPath); + } + +} diff --git a/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/CourseRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/CourseRepository.java new file mode 100644 index 000000000..b5f624b90 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/CourseRepository.java @@ -0,0 +1,30 @@ +package kr.co.yigil.travel.infrastructure; + +import java.util.Optional; +import kr.co.yigil.member.Member; +import kr.co.yigil.travel.domain.Course; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CourseRepository extends JpaRepository { + + Optional findByIdAndMemberId(Long courseId, Long memberId); + + @Query("SELECT c FROM Course c JOIN c.spots s WHERE s.place.id = :placeId AND c.isPrivate = false") + Slice findBySpotPlaceId(@Param("placeId") Long placeId, Pageable pageable); + + Slice findBySpots_PlaceIdAndIsPrivateFalse(Long placeId, Pageable pageable); + + Page findAllByMemberId(Long memberId, Pageable pageable); + + Page findAllByMemberIdAndIsPrivate(Long memberId, boolean isPrivate, Pageable pageable); + + Slice findAllByMember(Member member); + + @Query("SELECT c FROM Course c JOIN c.spots s WHERE s.place.name LIKE %:keyword% AND c.isPrivate = false") + Slice findByPlaceNameContaining(@Param("keyword") String keyword, Pageable pageable); +} \ No newline at end of file diff --git a/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/SpotQueryDslRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/SpotQueryDslRepository.java new file mode 100644 index 000000000..668e46dc0 --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/SpotQueryDslRepository.java @@ -0,0 +1,87 @@ +package kr.co.yigil.travel.infrastructure; + +import static org.springframework.util.ObjectUtils.isEmpty; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.ArrayList; +import java.util.List; +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.domain.QSpot; +import kr.co.yigil.travel.domain.Spot; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class SpotQueryDslRepository { + + private final JPAQueryFactory jpaQueryFactory; + public Page findAllByMemberIdAndIsPrivate(Long memberId, Selected visibility, Pageable pageable) { + QSpot spot = QSpot.spot; + BooleanBuilder builder = new BooleanBuilder(); + + if(memberId != null) { + builder.and(spot.member.id.eq(memberId)); + } + builder.and(spot.isInCourse.eq(false)); + + switch (visibility) { + case Selected.PRIVATE -> builder.and(spot.isPrivate.eq(true)); + case Selected.PUBLIC -> builder.and(spot.isPrivate.eq(false)); + case Selected.ALL -> { + }// 'all'일 때는 아무런 필터링을 하지 않습니다. + default -> + throw new IllegalArgumentException("Invalid visibility value: " + visibility); + } + + Predicate predicate = builder.getValue(); + List> orderSpecifiers = getOrderSpecifiers(pageable.getSort()); + + List spots = jpaQueryFactory.select(spot) + .from(spot) + .where(predicate) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + long total = jpaQueryFactory.selectFrom(spot) + .where(predicate) + .fetchCount(); + + return new PageImpl<>(spots, pageable, total); + + } + + private List> getOrderSpecifiers(Sort sort){ + + List> specifiers = new ArrayList<>(); + + if(!isEmpty(sort)){ + for(Sort.Order order:sort){ + if(order.getProperty().equals("rate") || order.getProperty().equals("createdAt")) + specifiers.add(getSortedColumn(order.getDirection().isAscending() ? Order.ASC : Order.DESC, QSpot.spot, order.getProperty())); + OrderSpecifier specifier = getSortedColumn(order.getDirection().isAscending() ? Order.ASC : Order.DESC, QSpot.spot, order.getProperty()); + specifiers.add(specifier); + } + } + return specifiers; + } + + public static OrderSpecifier getSortedColumn(Order order, Path parent, String fieldName) { + Path fieldPath = Expressions.path(Object.class, parent, fieldName); + + return new OrderSpecifier(order, fieldPath); + } + +} \ No newline at end of file diff --git a/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/SpotRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/SpotRepository.java new file mode 100644 index 000000000..acdea47bf --- /dev/null +++ b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/SpotRepository.java @@ -0,0 +1,38 @@ +package kr.co.yigil.travel.infrastructure; + +import java.time.LocalDateTime; +import java.util.Optional; +import kr.co.yigil.travel.domain.Spot; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface SpotRepository extends JpaRepository { + + Slice findAllByPlaceIdAndIsInCourseIsFalseAndIsPrivateIsFalse(Long placeId, Pageable pageable); + + Optional findTopByPlaceIdAndMemberId(Long placeId, Long memberId); + + @Query("SELECT count(s) FROM Spot s WHERE s.place.id = :placeId AND s.isDeleted = false") + int countByPlaceId(@Param("placeId") Long placeId); + + Page findAllByMemberIdAndIsInCourseIsFalse(Long memberId, Pageable pageable); + Page findAllByMemberIdAndIsPrivateAndIsInCourseFalse(Long memberId, boolean isPrivate, Pageable pageable); + + @Query("SELECT SUM(s.rate) FROM Spot s WHERE s.place.id = :placeId") + Optional findTotalRateByPlaceId(@Param("placeId") Long placeId); + + Page findAllByMemberId(Long memberId, Pageable pageable); + Page findAllByMemberIdAndIsPrivate(Long memberId, boolean isPrivate, Pageable pageable); + + @Query("SELECT s.place, COUNT(s) AS referenceCount FROM Spot s WHERE s.createdAt BETWEEN :startDate AND :endDate GROUP BY s.place ORDER BY referenceCount DESC") + Slice findPlaceReferenceCountBetweenDates(LocalDateTime startDate, LocalDateTime endDate, Pageable pageable); + + @Query("SELECT s.place, COUNT(s) AS referenceCount, m.ages, m.gender FROM Spot s INNER JOIN s.member m WHERE s.createdAt BETWEEN :startDate AND :endDate GROUP BY s.place, m.ages, m.gender ORDER BY referenceCount DESC, m.ages asc, m.gender ASC") + Slice findPlaceReferenceCountGroupByDemographicBetweenDates(LocalDateTime startDate, LocalDateTime endDate, Pageable pageable); +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/repository/TravelRepository.java b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/TravelRepository.java similarity index 55% rename from backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/repository/TravelRepository.java rename to backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/TravelRepository.java index e28ee783e..85c8e0933 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/repository/TravelRepository.java +++ b/backend/support/domain/src/main/java/kr/co/yigil/travel/infrastructure/TravelRepository.java @@ -1,8 +1,10 @@ -package kr.co.yigil.travel.domain.repository; - -import org.springframework.data.jpa.repository.JpaRepository; +package kr.co.yigil.travel.infrastructure; +import java.util.Optional; import kr.co.yigil.travel.domain.Travel; +import org.springframework.data.jpa.repository.JpaRepository; public interface TravelRepository extends JpaRepository { + + Optional findByIdAndMemberId(Long travelId, Long memberId); } \ No newline at end of file diff --git a/backend/yigil-admin/Dockerfile b/backend/yigil-admin/Dockerfile new file mode 100644 index 000000000..eeac3098c --- /dev/null +++ b/backend/yigil-admin/Dockerfile @@ -0,0 +1,9 @@ +FROM openjdk:21-jdk + +WORKDIR /app + +COPY build/libs/yigil-admin-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE @YIGIL_ADMIN_PORT@ + +CMD ["java", "-jar", "app.jar"] diff --git a/backend/yigil-admin/build.gradle b/backend/yigil-admin/build.gradle new file mode 100644 index 000000000..411640924 --- /dev/null +++ b/backend/yigil-admin/build.gradle @@ -0,0 +1,67 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' + id 'org.jetbrains.kotlin.jvm' +} + +bootJar { + mainClass = 'kr.co.yigil.AdminApplication' +} + +group = 'kr.co.yigil' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':support:log') + implementation project(':support:domain') + + implementation 'org.springframework.boot:spring-boot-starter-web-services' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.528' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'org.postgresql:postgresql' + implementation 'org.postgresql:postgresql' + implementation 'org.hibernate:hibernate-core:6.4.0.Final' + implementation 'org.hibernate:hibernate-spatial:6.4.0.Final' + + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + implementation 'com.github.maricn:logback-slack-appender:1.6.1' + + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64' + + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + + implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/backend/yigil-admin/src/main/generated/kr/co/yigil/admin/domain/admin/QAdmin.java b/backend/yigil-admin/src/main/generated/kr/co/yigil/admin/domain/admin/QAdmin.java new file mode 100644 index 000000000..426bacf65 --- /dev/null +++ b/backend/yigil-admin/src/main/generated/kr/co/yigil/admin/domain/admin/QAdmin.java @@ -0,0 +1,48 @@ +package kr.co.yigil.admin.domain.admin; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QAdmin is a Querydsl query type for Admin + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QAdmin extends EntityPathBase { + + private static final long serialVersionUID = 1825672788L; + + public static final QAdmin admin = new QAdmin("admin"); + + public final StringPath email = createString("email"); + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath nickname = createString("nickname"); + + public final StringPath password = createString("password"); + + public final StringPath profileImageUrl = createString("profileImageUrl"); + + public final ListPath roles = this.createList("roles", String.class, StringPath.class, PathInits.DIRECT2); + + public QAdmin(String variable) { + super(Admin.class, forVariable(variable)); + } + + public QAdmin(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QAdmin(PathMetadata metadata) { + super(Admin.class, metadata); + } + +} + diff --git a/backend/yigil-admin/src/main/generated/kr/co/yigil/admin/interfaces/dto/mapper/AdminMapperImpl.java b/backend/yigil-admin/src/main/generated/kr/co/yigil/admin/interfaces/dto/mapper/AdminMapperImpl.java new file mode 100644 index 000000000..a59f0b01b --- /dev/null +++ b/backend/yigil-admin/src/main/generated/kr/co/yigil/admin/interfaces/dto/mapper/AdminMapperImpl.java @@ -0,0 +1,77 @@ +package kr.co.yigil.admin.interfaces.dto.mapper; + +import javax.annotation.processing.Generated; +import kr.co.yigil.admin.domain.admin.AdminCommand; +import kr.co.yigil.admin.domain.admin.AdminInfo; +import kr.co.yigil.admin.interfaces.dto.request.AdminPasswordUpdateRequest; +import kr.co.yigil.admin.interfaces.dto.request.LoginRequest; +import kr.co.yigil.admin.interfaces.dto.response.AdminDetailInfoResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminInfoResponse; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-05T16:25:42+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class AdminMapperImpl implements AdminMapper { + + @Override + public AdminCommand.LoginRequest toCommand(LoginRequest loginRequest) { + if ( loginRequest == null ) { + return null; + } + + AdminCommand.LoginRequest.LoginRequestBuilder loginRequest1 = AdminCommand.LoginRequest.builder(); + + loginRequest1.email( loginRequest.getEmail() ); + loginRequest1.password( loginRequest.getPassword() ); + + return loginRequest1.build(); + } + + @Override + public AdminCommand.AdminPasswordUpdateRequest toCommand(AdminPasswordUpdateRequest updateRequest) { + if ( updateRequest == null ) { + return null; + } + + AdminCommand.AdminPasswordUpdateRequest.AdminPasswordUpdateRequestBuilder adminPasswordUpdateRequest = AdminCommand.AdminPasswordUpdateRequest.builder(); + + adminPasswordUpdateRequest.existingPassword( updateRequest.getExistingPassword() ); + adminPasswordUpdateRequest.newPassword( updateRequest.getNewPassword() ); + + return adminPasswordUpdateRequest.build(); + } + + @Override + public AdminInfoResponse toResponse(AdminInfo.AdminInfoResponse info) { + if ( info == null ) { + return null; + } + + AdminInfoResponse adminInfoResponse = new AdminInfoResponse(); + + adminInfoResponse.setNickname( info.getNickname() ); + adminInfoResponse.setProfileUrl( info.getProfileUrl() ); + + return adminInfoResponse; + } + + @Override + public AdminDetailInfoResponse toResponse(AdminInfo.AdminDetailInfoResponse info) { + if ( info == null ) { + return null; + } + + AdminDetailInfoResponse adminDetailInfoResponse = new AdminDetailInfoResponse(); + + adminDetailInfoResponse.setNickname( info.getNickname() ); + adminDetailInfoResponse.setProfileUrl( info.getProfileUrl() ); + adminDetailInfoResponse.setEmail( info.getEmail() ); + adminDetailInfoResponse.setPassword( info.getPassword() ); + + return adminDetailInfoResponse; + } +} diff --git a/backend/yigil-admin/src/main/generated/kr/co/yigil/admin/interfaces/dto/mapper/AdminSignupMapperImpl.java b/backend/yigil-admin/src/main/generated/kr/co/yigil/admin/interfaces/dto/mapper/AdminSignupMapperImpl.java new file mode 100644 index 000000000..a6bac962b --- /dev/null +++ b/backend/yigil-admin/src/main/generated/kr/co/yigil/admin/interfaces/dto/mapper/AdminSignupMapperImpl.java @@ -0,0 +1,52 @@ +package kr.co.yigil.admin.interfaces.dto.mapper; + +import java.time.format.DateTimeFormatter; +import javax.annotation.processing.Generated; +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpCommand; +import kr.co.yigil.admin.interfaces.dto.AdminSignUpInfoDto; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignupRequest; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-06T15:40:25+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)" +) +@Component +public class AdminSignupMapperImpl implements AdminSignupMapper { + + private final DateTimeFormatter dateTimeFormatter_yyyy_MM_dd_HH_mm_ss_11333195168 = DateTimeFormatter.ofPattern( "yyyy-MM-dd HH:mm:ss" ); + + @Override + public AdminSignUpCommand.AdminSignUpRequest toCommand(AdminSignupRequest adminSignupRequest) { + if ( adminSignupRequest == null ) { + return null; + } + + AdminSignUpCommand.AdminSignUpRequest.AdminSignUpRequestBuilder adminSignUpRequest = AdminSignUpCommand.AdminSignUpRequest.builder(); + + adminSignUpRequest.email( adminSignupRequest.getEmail() ); + adminSignUpRequest.nickname( adminSignupRequest.getNickname() ); + + return adminSignUpRequest.build(); + } + + @Override + public AdminSignUpInfoDto toAdminSignUpInfoDto(AdminSignUp adminSignUp) { + if ( adminSignUp == null ) { + return null; + } + + AdminSignUpInfoDto adminSignUpInfoDto = new AdminSignUpInfoDto(); + + adminSignUpInfoDto.setId( adminSignUp.getId() ); + adminSignUpInfoDto.setEmail( adminSignUp.getEmail() ); + adminSignUpInfoDto.setNickname( adminSignUp.getNickname() ); + if ( adminSignUp.getCreatedAt() != null ) { + adminSignUpInfoDto.setRequestDatetime( dateTimeFormatter_yyyy_MM_dd_HH_mm_ss_11333195168.format( adminSignUp.getCreatedAt() ) ); + } + + return adminSignUpInfoDto; + } +} diff --git a/backend/yigil-admin/src/main/generated/kr/co/yigil/comment/infterfaces/dto/mapper/CommentMapperImpl.java b/backend/yigil-admin/src/main/generated/kr/co/yigil/comment/infterfaces/dto/mapper/CommentMapperImpl.java new file mode 100644 index 000000000..97a672e1f --- /dev/null +++ b/backend/yigil-admin/src/main/generated/kr/co/yigil/comment/infterfaces/dto/mapper/CommentMapperImpl.java @@ -0,0 +1,79 @@ +package kr.co.yigil.comment.infterfaces.dto.mapper; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; +import kr.co.yigil.comment.domain.CommentInfo; +import kr.co.yigil.comment.infterfaces.dto.CommentDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-08T18:10:51+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)" +) +@Component +public class CommentMapperImpl implements CommentMapper { + + @Override + public CommentDto.CommentUnitDto of(CommentInfo.CommentListUnit info) { + if ( info == null ) { + return null; + } + + List children = null; + Long id = null; + String memberNickname = null; + Long memberId = null; + String content = null; + LocalDateTime createdAt = null; + + children = replyListUnitListToReplyUnitDtoList( info.getChildren() ); + id = info.getId(); + memberNickname = info.getMemberNickname(); + memberId = info.getMemberId(); + content = info.getContent(); + createdAt = info.getCreatedAt(); + + CommentDto.CommentUnitDto commentUnitDto = new CommentDto.CommentUnitDto( id, memberNickname, memberId, content, createdAt, children ); + + return commentUnitDto; + } + + @Override + public CommentDto.ReplyUnitDto of(CommentInfo.ReplyListUnit info) { + if ( info == null ) { + return null; + } + + Long id = null; + String memberNickname = null; + Long memberId = null; + String content = null; + LocalDateTime createdAt = null; + + id = info.getId(); + memberNickname = info.getMemberNickname(); + memberId = info.getMemberId(); + content = info.getContent(); + createdAt = info.getCreatedAt(); + + CommentDto.ReplyUnitDto replyUnitDto = new CommentDto.ReplyUnitDto( id, memberNickname, memberId, content, createdAt ); + + return replyUnitDto; + } + + protected List replyListUnitListToReplyUnitDtoList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( CommentInfo.ReplyListUnit replyListUnit : list ) { + list1.add( of( replyListUnit ) ); + } + + return list1; + } +} diff --git a/backend/yigil-admin/src/main/generated/kr/co/yigil/member/interfaces/dto/mapper/MemberMapperImpl.java b/backend/yigil-admin/src/main/generated/kr/co/yigil/member/interfaces/dto/mapper/MemberMapperImpl.java new file mode 100644 index 000000000..5a4e4f11c --- /dev/null +++ b/backend/yigil-admin/src/main/generated/kr/co/yigil/member/interfaces/dto/mapper/MemberMapperImpl.java @@ -0,0 +1,37 @@ +package kr.co.yigil.member.interfaces.dto.mapper; + +import java.time.format.DateTimeFormatter; +import javax.annotation.processing.Generated; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.interfaces.dto.response.MemberInfoDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-08T10:55:32+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)" +) +@Component +public class MemberMapperImpl implements MemberMapper { + + private final DateTimeFormatter dateTimeFormatter_yyyy_MM_dd_HH_mm_ss_11333195168 = DateTimeFormatter.ofPattern( "yyyy-MM-dd HH:mm:ss" ); + + @Override + public MemberInfoDto toDto(Member member) { + if ( member == null ) { + return null; + } + + MemberInfoDto.MemberInfoDtoBuilder memberInfoDto = MemberInfoDto.builder(); + + memberInfoDto.memberId( member.getId() ); + memberInfoDto.nickname( member.getNickname() ); + memberInfoDto.profileImageUrl( member.getProfileImageUrl() ); + memberInfoDto.status( member.getStatus() ); + if ( member.getJoinedAt() != null ) { + memberInfoDto.joinedAt( dateTimeFormatter_yyyy_MM_dd_HH_mm_ss_11333195168.format( member.getJoinedAt() ) ); + } + + return memberInfoDto.build(); + } +} diff --git a/backend/yigil-admin/src/main/generated/kr/co/yigil/notice/interfaces/dto/mapper/NoticeMapperImpl.java b/backend/yigil-admin/src/main/generated/kr/co/yigil/notice/interfaces/dto/mapper/NoticeMapperImpl.java new file mode 100644 index 000000000..2b5fc7986 --- /dev/null +++ b/backend/yigil-admin/src/main/generated/kr/co/yigil/notice/interfaces/dto/mapper/NoticeMapperImpl.java @@ -0,0 +1,110 @@ +package kr.co.yigil.notice.interfaces.dto.mapper; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; +import kr.co.yigil.notice.domain.NoticeCommand; +import kr.co.yigil.notice.domain.NoticeInfo; +import kr.co.yigil.notice.interfaces.dto.NoticeDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-05T16:25:42+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class NoticeMapperImpl implements NoticeMapper { + + @Override + public NoticeDto.NoticeListResponse toDto(NoticeInfo.NoticeListInfo response) { + if ( response == null ) { + return null; + } + + NoticeDto.NoticeListResponse.NoticeListResponseBuilder noticeListResponse = NoticeDto.NoticeListResponse.builder(); + + noticeListResponse.noticeList( noticeItemListToNoticeItemList( response.getNoticeList() ) ); + noticeListResponse.hasNext( response.isHasNext() ); + + return noticeListResponse.build(); + } + + @Override + public NoticeDto.NoticeItem of(NoticeInfo.NoticeItem noticeItem) { + if ( noticeItem == null ) { + return null; + } + + NoticeDto.NoticeItem.NoticeItemBuilder noticeItem1 = NoticeDto.NoticeItem.builder(); + + noticeItem1.id( noticeItem.getId() ); + noticeItem1.title( noticeItem.getTitle() ); + noticeItem1.content( noticeItem.getContent() ); + noticeItem1.author( noticeItem.getAuthor() ); + noticeItem1.createdAt( noticeItem.getCreatedAt() ); + noticeItem1.updatedAt( noticeItem.getUpdatedAt() ); + + return noticeItem1.build(); + } + + @Override + public NoticeDto.NoticeDetailResponse toDto(NoticeInfo.NoticeDetail response) { + if ( response == null ) { + return null; + } + + NoticeDto.NoticeDetailResponse.NoticeDetailResponseBuilder noticeDetailResponse = NoticeDto.NoticeDetailResponse.builder(); + + noticeDetailResponse.id( response.getId() ); + noticeDetailResponse.title( response.getTitle() ); + noticeDetailResponse.content( response.getContent() ); + noticeDetailResponse.createdAt( response.getCreatedAt() ); + noticeDetailResponse.updatedAt( response.getUpdatedAt() ); + + return noticeDetailResponse.build(); + } + + @Override + public NoticeCommand.NoticeCreateRequest toCommand(NoticeDto.NoticeCreateRequest request) { + if ( request == null ) { + return null; + } + + NoticeCommand.NoticeCreateRequest.NoticeCreateRequestBuilder noticeCreateRequest = NoticeCommand.NoticeCreateRequest.builder(); + + noticeCreateRequest.author( request.getAuthor() ); + noticeCreateRequest.title( request.getTitle() ); + noticeCreateRequest.content( request.getContent() ); + + return noticeCreateRequest.build(); + } + + @Override + public NoticeCommand.NoticeUpdateRequest toCommand(NoticeDto.NoticeUpdateRequest request) { + if ( request == null ) { + return null; + } + + NoticeCommand.NoticeUpdateRequest.NoticeUpdateRequestBuilder noticeUpdateRequest = NoticeCommand.NoticeUpdateRequest.builder(); + + noticeUpdateRequest.author( request.getAuthor() ); + noticeUpdateRequest.title( request.getTitle() ); + noticeUpdateRequest.content( request.getContent() ); + + return noticeUpdateRequest.build(); + } + + protected List noticeItemListToNoticeItemList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( NoticeInfo.NoticeItem noticeItem : list ) { + list1.add( of( noticeItem ) ); + } + + return list1; + } +} diff --git a/backend/yigil-admin/src/main/generated/kr/co/yigil/travel/course/interfaces/dto/mapper/CourseDtoMapperImpl.java b/backend/yigil-admin/src/main/generated/kr/co/yigil/travel/course/interfaces/dto/mapper/CourseDtoMapperImpl.java new file mode 100644 index 000000000..ad1c51a17 --- /dev/null +++ b/backend/yigil-admin/src/main/generated/kr/co/yigil/travel/course/interfaces/dto/mapper/CourseDtoMapperImpl.java @@ -0,0 +1,54 @@ +package kr.co.yigil.travel.course.interfaces.dto.mapper; + +import javax.annotation.processing.Generated; +import kr.co.yigil.travel.course.domain.CourseInfoDto; +import kr.co.yigil.travel.course.interfaces.dto.CourseDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-08T10:55:32+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)" +) +@Component +public class CourseDtoMapperImpl implements CourseDtoMapper { + + @Override + public CourseDto.CourseListUnit of(CourseInfoDto.CourseListUnit course) { + if ( course == null ) { + return null; + } + + CourseDto.CourseListUnit courseListUnit = new CourseDto.CourseListUnit(); + + courseListUnit.setCourseId( course.getCourseId() ); + courseListUnit.setTitle( course.getTitle() ); + courseListUnit.setCreatedAt( course.getCreatedAt() ); + courseListUnit.setFavorCount( course.getFavorCount() ); + courseListUnit.setCommentCount( course.getCommentCount() ); + + return courseListUnit; + } + + @Override + public CourseDto.CourseDetailResponse toDetailDto(CourseInfoDto.CourseDetailInfo course) { + if ( course == null ) { + return null; + } + + CourseDto.CourseDetailResponse courseDetailResponse = new CourseDto.CourseDetailResponse(); + + courseDetailResponse.setCourseId( course.getCourseId() ); + courseDetailResponse.setTitle( course.getTitle() ); + courseDetailResponse.setContent( course.getContent() ); + courseDetailResponse.setMapStaticImageUrl( course.getMapStaticImageUrl() ); + courseDetailResponse.setCreatedAt( course.getCreatedAt() ); + courseDetailResponse.setRate( course.getRate() ); + courseDetailResponse.setFavorCount( course.getFavorCount() ); + courseDetailResponse.setCommentCount( course.getCommentCount() ); + courseDetailResponse.setWriterId( course.getWriterId() ); + courseDetailResponse.setWriterName( course.getWriterName() ); + + return courseDetailResponse; + } +} diff --git a/backend/yigil-admin/src/main/generated/kr/co/yigil/travel/spot/interfaces/dto/mapper/SpotDtoMapperImpl.java b/backend/yigil-admin/src/main/generated/kr/co/yigil/travel/spot/interfaces/dto/mapper/SpotDtoMapperImpl.java new file mode 100644 index 000000000..1f1e7b456 --- /dev/null +++ b/backend/yigil-admin/src/main/generated/kr/co/yigil/travel/spot/interfaces/dto/mapper/SpotDtoMapperImpl.java @@ -0,0 +1,61 @@ +package kr.co.yigil.travel.spot.interfaces.dto.mapper; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; +import kr.co.yigil.travel.spot.domain.SpotInfoDto; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-08T10:55:32+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)" +) +@Component +public class SpotDtoMapperImpl implements SpotDtoMapper { + + @Override + public SpotDto.SpotListInfo of(SpotInfoDto.SpotListUnit spot) { + if ( spot == null ) { + return null; + } + + SpotDto.SpotListInfo spotListInfo = new SpotDto.SpotListInfo(); + + spotListInfo.setSpotId( spot.getSpotId() ); + spotListInfo.setTitle( spot.getTitle() ); + spotListInfo.setCreatedAt( spot.getCreatedAt() ); + spotListInfo.setFavorCount( spot.getFavorCount() ); + spotListInfo.setCommentCount( spot.getCommentCount() ); + + return spotListInfo; + } + + @Override + public SpotDto.SpotDetailResponse of(SpotInfoDto.SpotDetailInfo spot) { + if ( spot == null ) { + return null; + } + + SpotDto.SpotDetailResponse spotDetailResponse = new SpotDto.SpotDetailResponse(); + + spotDetailResponse.setSpotId( spot.getSpotId() ); + spotDetailResponse.setTitle( spot.getTitle() ); + spotDetailResponse.setContent( spot.getContent() ); + spotDetailResponse.setPlaceName( spot.getPlaceName() ); + spotDetailResponse.setMapStaticImageUrl( spot.getMapStaticImageUrl() ); + spotDetailResponse.setCreatedAt( spot.getCreatedAt() ); + spotDetailResponse.setRate( spot.getRate() ); + spotDetailResponse.setFavorCount( spot.getFavorCount() ); + spotDetailResponse.setCommentCount( spot.getCommentCount() ); + List list = spot.getImageUrls(); + if ( list != null ) { + spotDetailResponse.setImageUrls( new ArrayList( list ) ); + } + spotDetailResponse.setWriterId( spot.getWriterId() ); + spotDetailResponse.setWriterName( spot.getWriterName() ); + + return spotDetailResponse; + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/AdminApplication.java b/backend/yigil-admin/src/main/java/kr/co/yigil/AdminApplication.java new file mode 100644 index 000000000..8ab9181a2 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/AdminApplication.java @@ -0,0 +1,14 @@ +package kr.co.yigil; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.ServletComponentScan; + +@ServletComponentScan +@SpringBootApplication +public class AdminApplication { + + public static void main(String[] args) { + SpringApplication.run(AdminApplication.class, args); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/application/AdminFacade.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/application/AdminFacade.java new file mode 100644 index 000000000..80f5485d3 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/application/AdminFacade.java @@ -0,0 +1,66 @@ +package kr.co.yigil.admin.application; + +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.admin.AdminCommand.AdminPasswordUpdateRequest; +import kr.co.yigil.admin.domain.admin.AdminCommand.LoginRequest; +import kr.co.yigil.admin.domain.admin.AdminInfo.AdminDetailInfoResponse; +import kr.co.yigil.admin.domain.admin.AdminInfo.AdminInfoResponse; +import kr.co.yigil.admin.domain.admin.AdminService; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpCommand.AdminSignUpRequest; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpService; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignUpListRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpAcceptRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpRejectRequest; +import kr.co.yigil.auth.dto.JwtToken; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class AdminFacade { + + private final AdminService adminService; + private final AdminSignUpService adminSignUpService; + + public void sendSignUpRequest(AdminSignUpRequest command) { + adminSignUpService.sendSignUpRequest(command); + } + + public Page getSignUpRequestList(AdminSignUpListRequest request) { + return adminSignUpService.getAdminSignUpList(request); + } + + public void acceptAdminSignUp(SignUpAcceptRequest request) { + adminSignUpService.acceptAdminSignUp(request); + } + + public void rejectAdminSignUp(SignUpRejectRequest request) { + adminSignUpService.rejectAdminSignUp(request); + } + + public JwtToken signIn(LoginRequest command) throws Exception { + return adminService.signIn(command); + } + + public AdminInfoResponse getAdminInfoByEmail(String email) { + return adminService.getAdminInfoByEmail(email); + } + + public AdminDetailInfoResponse getAdminDetailInfoByEmail(String email) { + return adminService.getAdminDetailInfoByEmail(email); + } + + public void testSignUp() { + adminService.testSignUp(); + } + + public void updateProfileImage(String email, MultipartFile profileImageFile) { + adminService.updateProfileImage(email, profileImageFile); + } + + public void updatePassword(String email, AdminPasswordUpdateRequest command) { + adminService.updatePassword(email, command); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminCommand.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminCommand.java new file mode 100644 index 000000000..3e091761d --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminCommand.java @@ -0,0 +1,39 @@ +package kr.co.yigil.admin.domain.admin; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.web.multipart.MultipartFile; + +public class AdminCommand { + @Getter + @Builder + @ToString + public static class LoginRequest { + + private String email; + private String password; + + } + + @Getter + @Builder + @ToString + public static class AdminUpdateRequest { + private String nickname; + private MultipartFile profileImageFile; + private String password; + + } + + @Getter + @Builder + @ToString + public static class AdminPasswordUpdateRequest { + private String existingPassword; + private String newPassword; + + } + + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminInfo.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminInfo.java new file mode 100644 index 000000000..814a4a6b5 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminInfo.java @@ -0,0 +1,45 @@ +package kr.co.yigil.admin.domain.admin; + +import java.util.Optional; +import kr.co.yigil.admin.domain.Admin; +import lombok.Getter; +import lombok.ToString; +import kr.co.yigil.file.AttachFile; + +public class AdminInfo { + + @Getter + @ToString + public static class AdminInfoResponse { + private final String nickname; + private final String profileUrl; + + public AdminInfoResponse(Admin admin) { + nickname = admin.getNickname(); + profileUrl = Optional.ofNullable(admin.getProfileImage()) + .map(AttachFile::getFileUrl) + .orElse(""); + } + + } + + @Getter + @ToString + public static class AdminDetailInfoResponse { + private final String nickname; + private final String profileUrl; + private final String email; + private final String password; + + public AdminDetailInfoResponse(Admin admin) { + nickname = admin.getNickname(); + profileUrl = Optional.ofNullable(admin.getProfileImage()) + .map(AttachFile::getFileUrl) + .orElse(""); + email = admin.getEmail(); + password = admin.getPassword(); + } + + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminReader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminReader.java new file mode 100644 index 000000000..a43bc9996 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminReader.java @@ -0,0 +1,12 @@ +package kr.co.yigil.admin.domain.admin; + +import kr.co.yigil.admin.domain.Admin; + +public interface AdminReader { + + boolean existsByEmailOrNickname(String email, String nickname); + + Admin getAdminByEmail(String email); + + Admin getAdmin(Long adminId); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminService.java new file mode 100644 index 000000000..d52e3d6be --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminService.java @@ -0,0 +1,28 @@ +package kr.co.yigil.admin.domain.admin; + +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.domain.admin.AdminCommand.AdminPasswordUpdateRequest; +import kr.co.yigil.admin.domain.admin.AdminCommand.LoginRequest; +import kr.co.yigil.admin.domain.admin.AdminInfo.AdminDetailInfoResponse; +import kr.co.yigil.auth.dto.JwtToken; +import org.springframework.web.multipart.MultipartFile; + + +public interface AdminService { + + JwtToken signIn(LoginRequest command); + + AdminInfo.AdminInfoResponse getAdminInfoByEmail(String email); + + AdminDetailInfoResponse getAdminDetailInfoByEmail(String email); + + void testSignUp(); + + void updateProfileImage(String email, MultipartFile profileImageFile); + + void updatePassword(String email, AdminPasswordUpdateRequest command); + + Admin getAdmin(String username); + + Long getAdminId(); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminServiceImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminServiceImpl.java new file mode 100644 index 000000000..00d5bbd69 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminServiceImpl.java @@ -0,0 +1,121 @@ +package kr.co.yigil.admin.domain.admin; + +import static kr.co.yigil.global.exception.ExceptionCode.ADMIN_PASSWORD_DOES_NOT_MATCH; + +import java.util.ArrayList; +import java.util.List; +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.domain.admin.AdminCommand.AdminPasswordUpdateRequest; +import kr.co.yigil.admin.domain.admin.AdminCommand.LoginRequest; +import kr.co.yigil.admin.domain.admin.AdminInfo.AdminDetailInfoResponse; +import kr.co.yigil.auth.application.JwtTokenProvider; +import kr.co.yigil.auth.dto.JwtToken; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.domain.FileUploader; +import kr.co.yigil.global.exception.AuthException; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class AdminServiceImpl implements AdminService { + + private final AdminReader adminReader; + private final AdminStore adminStore; + + private final AuthenticationManager authenticationManager; + private final JwtTokenProvider jwtTokenProvider; + private final PasswordEncoder passwordEncoder; + + private final FileUploader fileUploader; + + @Override + @Transactional(readOnly = true) + public JwtToken signIn(LoginRequest command) { + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + command.getEmail(), command.getPassword()); + + Authentication authentication = authenticationManager.authenticate(authenticationToken); + + return jwtTokenProvider.generateToken(authentication); + } + + @Override + @Transactional(readOnly = true) + public AdminInfo.AdminInfoResponse getAdminInfoByEmail(String email) { + Admin admin = adminReader.getAdminByEmail(email); + return new AdminInfo.AdminInfoResponse(admin); + } + + @Override + @Transactional(readOnly = true) + public AdminDetailInfoResponse getAdminDetailInfoByEmail(String email) { + Admin admin = adminReader.getAdminByEmail(email); + return new AdminDetailInfoResponse(admin); + } + + @Override + @Transactional + public void updateProfileImage(String email, MultipartFile profileImageFile) { + Admin admin = adminReader.getAdminByEmail(email); + AttachFile updatedProfile = fileUploader.upload(profileImageFile); + + admin.updateProfileImage(updatedProfile); + } + + @Override + @Transactional + public void updatePassword(String email, AdminPasswordUpdateRequest command) { + Admin admin = adminReader.getAdminByEmail(email); + + if (!passwordEncoder.matches(command.getExistingPassword(), admin.getPassword())) { + throw new AuthException(ADMIN_PASSWORD_DOES_NOT_MATCH); + } + + String encodedPassword = passwordEncoder.encode(command.getNewPassword()); + admin.updatePassword(encodedPassword); + + } + + @Override + public Admin getAdmin(String username) { + return adminReader.getAdminByEmail(username); + } + + @Override + public Long getAdminId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assert authentication != null; + if (authentication.getPrincipal() instanceof UserDetails userDetails) { + String userEmail = userDetails.getUsername(); + Admin admin = getAdmin(userEmail); + return admin.getId(); + } + + throw new BadRequestException(ExceptionCode.ADMIN_NOT_FOUND); + } + + @Override + @Transactional + public void testSignUp() { + List roles = new ArrayList<>(); + roles.add("USER"); + + Admin admin = new Admin("kiit7@naver.com", + passwordEncoder.encode("0000"), + "스톤", + roles); + + adminStore.store(admin); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminStore.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminStore.java new file mode 100644 index 000000000..ac0e83d0f --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/admin/AdminStore.java @@ -0,0 +1,8 @@ +package kr.co.yigil.admin.domain.admin; + +import kr.co.yigil.admin.domain.Admin; + +public interface AdminStore { + + void store(Admin admin); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpCommand.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpCommand.java new file mode 100644 index 000000000..465a13c22 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpCommand.java @@ -0,0 +1,23 @@ +package kr.co.yigil.admin.domain.adminSignUp; + +import kr.co.yigil.admin.domain.AdminSignUp; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +public class AdminSignUpCommand { + + @Getter + @Builder + @ToString + public static class AdminSignUpRequest { + + private String email; + private String nickname; + + public AdminSignUp toEntity() { + return new AdminSignUp(email, nickname); + } + + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpReader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpReader.java new file mode 100644 index 000000000..65f50a2f4 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpReader.java @@ -0,0 +1,12 @@ +package kr.co.yigil.admin.domain.adminSignUp; + +import kr.co.yigil.admin.domain.AdminSignUp; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface AdminSignUpReader { + + Page findAll(Pageable pageable); + + AdminSignUp findById(Long id); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpService.java new file mode 100644 index 000000000..625339587 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpService.java @@ -0,0 +1,19 @@ +package kr.co.yigil.admin.domain.adminSignUp; + +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpCommand.AdminSignUpRequest; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignUpListRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpAcceptRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpRejectRequest; +import org.springframework.data.domain.Page; + +public interface AdminSignUpService { + + void sendSignUpRequest(AdminSignUpRequest command); + + Page getAdminSignUpList(AdminSignUpListRequest request); + + void acceptAdminSignUp(SignUpAcceptRequest request); + + void rejectAdminSignUp(SignUpRejectRequest request); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpServiceImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpServiceImpl.java new file mode 100644 index 000000000..98a274028 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpServiceImpl.java @@ -0,0 +1,117 @@ +package kr.co.yigil.admin.domain.adminSignUp; + +import static kr.co.yigil.global.exception.ExceptionCode.ADMIN_ALREADY_EXISTED; + +import java.util.ArrayList; +import java.util.List; +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.admin.AdminReader; +import kr.co.yigil.admin.domain.admin.AdminStore; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpCommand.AdminSignUpRequest; +import kr.co.yigil.admin.infrastructure.adminSignUp.AdminPasswordGenerator; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignUpListRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpAcceptRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpRejectRequest; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminSignUpServiceImpl implements AdminSignUpService { + + + private final AdminSignUpReader adminSignUpReader; + private final AdminSignUpStore adminSignUpStore; + private final EmailSender emailSender; + + private final AdminReader adminReader; + + private final AdminPasswordGenerator adminPasswordGenerator; + private final PasswordEncoder passwordEncoder; + + private final AdminStore adminStore; + + @Override + @Transactional + public void sendSignUpRequest(AdminSignUpRequest command) { + validateRequestAlreadySignedUp(command); + try { + adminSignUpStore.store(command.toEntity()); + } catch (DataIntegrityViolationException e) { + throw new BadRequestException(ADMIN_ALREADY_EXISTED); + } + } + + private void validateRequestAlreadySignedUp(AdminSignUpRequest command) { + if (adminReader.existsByEmailOrNickname(command.getEmail(), command.getNickname())) { + throw new BadRequestException(ADMIN_ALREADY_EXISTED); + } + } + + @Override + @Transactional(readOnly = true) + public Page getAdminSignUpList(AdminSignUpListRequest request) { + Pageable pageable = PageRequest.of(request.getPage() - 1, request.getDataCount()); + return adminSignUpReader.findAll(pageable); + } + + @Override + @Transactional + public void acceptAdminSignUp(SignUpAcceptRequest request) { + List acceptedAdminIds = request.getIds(); + signUpNewAdmins(acceptedAdminIds); + } + + private void signUpNewAdmins(List ids) { + List roles = new ArrayList<>(); + roles.add("USER"); + for (Long id : ids) { + signUpNewAdmin(id, roles); + } + } + + private void signUpNewAdmin(Long id, List roles) { + AdminSignUp signUp = adminSignUpReader.findById(id); + + String temporaryPassword = adminPasswordGenerator.generateRandomPassword(); + + Admin admin = new Admin(signUp.getEmail(), + passwordEncoder.encode(temporaryPassword), + signUp.getNickname(), + roles); + + adminStore.store(admin); + emailSender.sendAcceptEmail(signUp, temporaryPassword); + adminSignUpStore.remove(signUp); + } + + private void deleteAdminSignUpRequest(Long id) { + AdminSignUp signUp = adminSignUpReader.findById(id); + + emailSender.sendRejectEmail(signUp); + adminSignUpStore.remove(signUp); + } + + private void deleteAdminSignUpRequests(List ids) { + for (Long id : ids) { + deleteAdminSignUpRequest(id); + } + } + + @Override + @Transactional + public void rejectAdminSignUp(SignUpRejectRequest request) { + List rejectedAdminIds = request.getIds(); + deleteAdminSignUpRequests(rejectedAdminIds); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpStore.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpStore.java new file mode 100644 index 000000000..b2b17821a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpStore.java @@ -0,0 +1,10 @@ +package kr.co.yigil.admin.domain.adminSignUp; + +import kr.co.yigil.admin.domain.AdminSignUp; + +public interface AdminSignUpStore { + + void store(AdminSignUp entity); + + void remove(AdminSignUp signUp); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/EmailSender.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/EmailSender.java new file mode 100644 index 000000000..6cd58c5f3 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/domain/adminSignUp/EmailSender.java @@ -0,0 +1,10 @@ +package kr.co.yigil.admin.domain.adminSignUp; + +import kr.co.yigil.admin.domain.AdminSignUp; + +public interface EmailSender { + + void sendAcceptEmail(AdminSignUp signUp, String password); + + void sendRejectEmail(AdminSignUp signUp); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/admin/AdminReaderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/admin/AdminReaderImpl.java new file mode 100644 index 000000000..486a897be --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/admin/AdminReaderImpl.java @@ -0,0 +1,35 @@ +package kr.co.yigil.admin.infrastructure.admin; + +import static kr.co.yigil.global.exception.ExceptionCode.ADMIN_NOT_FOUND; + +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.domain.admin.AdminReader; +import kr.co.yigil.admin.infrastructure.AdminRepository; +import kr.co.yigil.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminReaderImpl implements AdminReader { + + private final AdminRepository adminRepository; + + @Override + public boolean existsByEmailOrNickname(String email, String nickname) { + return adminRepository.existsByEmailOrNickname(email, nickname); + } + + @Override + public Admin getAdminByEmail(String email) { + return adminRepository.findByEmail(email).orElseThrow(() -> + new BadRequestException(ADMIN_NOT_FOUND)); + } + + @Override + public Admin getAdmin(Long adminId) { + return adminRepository.findById(adminId).orElseThrow( + () -> new BadRequestException(ADMIN_NOT_FOUND) + ); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/admin/AdminStoreImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/admin/AdminStoreImpl.java new file mode 100644 index 000000000..8882f0705 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/admin/AdminStoreImpl.java @@ -0,0 +1,18 @@ +package kr.co.yigil.admin.infrastructure.admin; + +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.domain.admin.AdminStore; +import kr.co.yigil.admin.infrastructure.AdminRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminStoreImpl implements AdminStore { + private final AdminRepository adminRepository; + + @Override + public void store(Admin admin) { + adminRepository.save(admin); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminPasswordGenerator.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminPasswordGenerator.java new file mode 100644 index 000000000..d3ef3a202 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminPasswordGenerator.java @@ -0,0 +1,29 @@ +package kr.co.yigil.admin.infrastructure.adminSignUp; + +import java.util.Random; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminPasswordGenerator { + private static final String CHAR_LOWER = "abcdefghijklmnopqrstuvwxyz"; + private static final String CHAR_UPPER = CHAR_LOWER.toUpperCase(); + private static final String NUMBER = "0123456789"; + private static final String PASSWORD_ALLOW_BASE = CHAR_LOWER + CHAR_UPPER + NUMBER; + private static final int PASSWORD_LENGTH = 10; + + public String generateRandomPassword() { + Random random = new Random(); + StringBuilder sb = new StringBuilder(PASSWORD_LENGTH); + + for (int i = 0; i < PASSWORD_LENGTH; i++) { + int rndCharAt = random.nextInt(PASSWORD_ALLOW_BASE.length()); + char rndChar = PASSWORD_ALLOW_BASE.charAt(rndCharAt); + sb.append(rndChar); + } + + return sb.toString(); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpReaderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpReaderImpl.java new file mode 100644 index 000000000..cfbef31c1 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpReaderImpl.java @@ -0,0 +1,28 @@ +package kr.co.yigil.admin.infrastructure.adminSignUp; + +import static kr.co.yigil.global.exception.ExceptionCode.ADMIN_SIGNUP_REQUEST_NOT_FOUND; + +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpReader; +import kr.co.yigil.admin.infrastructure.AdminSignUpRepository; +import kr.co.yigil.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminSignUpReaderImpl implements AdminSignUpReader { + private final AdminSignUpRepository adminSignUpRepository; + @Override + public Page findAll(Pageable pageable) { + return adminSignUpRepository.findAll(pageable); + } + + @Override + public AdminSignUp findById(Long id) { + return adminSignUpRepository.findById(id) + .orElseThrow(() -> new BadRequestException(ADMIN_SIGNUP_REQUEST_NOT_FOUND)); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpStoreImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpStoreImpl.java new file mode 100644 index 000000000..d2530fa6d --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpStoreImpl.java @@ -0,0 +1,23 @@ +package kr.co.yigil.admin.infrastructure.adminSignUp; + +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpStore; +import kr.co.yigil.admin.infrastructure.AdminSignUpRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminSignUpStoreImpl implements AdminSignUpStore { + private final AdminSignUpRepository adminSignUpRepository; + + @Override + public void store(AdminSignUp entity) { + adminSignUpRepository.save(entity); + } + + @Override + public void remove(AdminSignUp signUp) { + adminSignUpRepository.delete(signUp); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/EmailSenderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/EmailSenderImpl.java new file mode 100644 index 000000000..cea91efa8 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/infrastructure/adminSignUp/EmailSenderImpl.java @@ -0,0 +1,31 @@ +package kr.co.yigil.admin.infrastructure.adminSignUp; + +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.adminSignUp.EmailSender; +import kr.co.yigil.email.EmailEventType; +import kr.co.yigil.email.EmailSendEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EmailSenderImpl implements EmailSender { + private final ApplicationEventPublisher eventPublisher; + + @Override + public void sendAcceptEmail(AdminSignUp signUp, String password) { + EmailSendEvent event = new EmailSendEvent(this, signUp.getEmail(), + "[이길로그] 관리자 서비스 가입이 완료되었습니다.", "", password, + EmailEventType.ADMIN_SIGN_UP_ACCEPT); + eventPublisher.publishEvent(event); + } + + @Override + public void sendRejectEmail(AdminSignUp signUp) { + EmailSendEvent event = new EmailSendEvent(this, signUp.getEmail(), + "[이길로그] 관리자 서비스 가입이 거절되었습니다.", "", "", EmailEventType.ADMIN_SIGN_UP_REJECT); + eventPublisher.publishEvent(event); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/controller/AdminApiController.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/controller/AdminApiController.java new file mode 100644 index 000000000..de4577096 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/controller/AdminApiController.java @@ -0,0 +1,113 @@ +package kr.co.yigil.admin.interfaces.controller; + +import kr.co.yigil.admin.application.AdminFacade; +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.admin.AdminCommand; +import kr.co.yigil.admin.domain.admin.AdminInfo; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpCommand.AdminSignUpRequest; +import kr.co.yigil.admin.interfaces.dto.mapper.AdminMapper; +import kr.co.yigil.admin.interfaces.dto.mapper.AdminSignupMapper; +import kr.co.yigil.admin.interfaces.dto.request.AdminPasswordUpdateRequest; +import kr.co.yigil.admin.interfaces.dto.request.AdminProfileImageUpdateRequest; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignUpListRequest; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignupRequest; +import kr.co.yigil.admin.interfaces.dto.request.LoginRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpAcceptRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpRejectRequest; +import kr.co.yigil.admin.interfaces.dto.response.AdminDetailInfoResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminInfoResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminPasswordUpdateResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminProfileImageUpdateResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminSignUpsResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminSignupResponse; +import kr.co.yigil.admin.interfaces.dto.response.SignUpAcceptResponse; +import kr.co.yigil.admin.interfaces.dto.response.SignUpRejectResponse; +import kr.co.yigil.auth.dto.JwtToken; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.User; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admins") +public class AdminApiController { + + private final AdminFacade adminFacade; + private final AdminSignupMapper adminSignupMapper; + private final AdminMapper adminMapper; + + @PostMapping("/signup") + public ResponseEntity sendSignUpRequest(@RequestBody AdminSignupRequest request) { + + AdminSignUpRequest command = adminSignupMapper.toCommand(request); + adminFacade.sendSignUpRequest(command); + + return ResponseEntity.ok(new AdminSignupResponse("회원가입 요청이 완료되었습니다.")); + } + + @GetMapping("/signup/list") + public ResponseEntity getSignUpRequestList(@ModelAttribute AdminSignUpListRequest request) { + Page adminSignUps = adminFacade.getSignUpRequestList(request); + AdminSignUpsResponse response = adminSignupMapper.toResponse(adminSignUps); + return ResponseEntity.ok(response); + } + + @PostMapping("/signup/accept") + public ResponseEntity acceptSignUp(@RequestBody SignUpAcceptRequest request) { + adminFacade.acceptAdminSignUp(request); + return ResponseEntity.ok(new SignUpAcceptResponse("가입 승인 완료")); + } + + @PostMapping("/signup/reject") + public ResponseEntity rejectSignUp(@RequestBody SignUpRejectRequest request) { + adminFacade.rejectAdminSignUp(request); + return ResponseEntity.ok(new SignUpRejectResponse("가입 거절 완료")); + } + + @PostMapping("/login") + public JwtToken login(@RequestBody LoginRequest request) throws Exception { + AdminCommand.LoginRequest command = adminMapper.toCommand(request); + return adminFacade.signIn(command); + } + + @GetMapping("/info") + public ResponseEntity getMemberInfo(@AuthenticationPrincipal User user) { + AdminInfo.AdminInfoResponse info = adminFacade.getAdminInfoByEmail(user.getUsername()); + AdminInfoResponse response = adminMapper.toResponse(info); + return ResponseEntity.ok(response); + } + + @GetMapping("/detail-info") + public ResponseEntity getMemberDetailInfo(@AuthenticationPrincipal User user) { + AdminInfo.AdminDetailInfoResponse info = adminFacade.getAdminDetailInfoByEmail(user.getUsername()); + AdminDetailInfoResponse response = adminMapper.toResponse(info); + return ResponseEntity.ok(response); + } + + @PostMapping("/profile-image") + public ResponseEntity updateProfileImage(@AuthenticationPrincipal User user, @ModelAttribute AdminProfileImageUpdateRequest request) { + adminFacade.updateProfileImage(user.getUsername(), request.getProfileImageFile()); + return ResponseEntity.ok(new AdminProfileImageUpdateResponse("어드민 프로필 이미지 수정 완료")); + } + + @PostMapping("/password") + public ResponseEntity updatePassword(@AuthenticationPrincipal User user, @RequestBody AdminPasswordUpdateRequest request) { + AdminCommand.AdminPasswordUpdateRequest command = adminMapper.toCommand(request); + adminFacade.updatePassword(user.getUsername(), command); + return ResponseEntity.ok(new AdminPasswordUpdateResponse("어드민 비밀번호 수정 완료")); + } + + @PostMapping("/test") + public ResponseEntity testSignUp() { + adminFacade.testSignUp(); + return ResponseEntity.ok("succeed test"); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/AdminSignUpInfoDto.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/AdminSignUpInfoDto.java new file mode 100644 index 000000000..6f4172fad --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/AdminSignUpInfoDto.java @@ -0,0 +1,17 @@ +package kr.co.yigil.admin.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AdminSignUpInfoDto { + + private Long id; + private String email; + private String nickname; + private String requestDatetime; + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/mapper/AdminMapper.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/mapper/AdminMapper.java new file mode 100644 index 000000000..5208b977d --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/mapper/AdminMapper.java @@ -0,0 +1,38 @@ +package kr.co.yigil.admin.interfaces.dto.mapper; + +import kr.co.yigil.admin.domain.admin.AdminCommand; +import kr.co.yigil.admin.domain.admin.AdminInfo; +import kr.co.yigil.admin.interfaces.dto.request.AdminPasswordUpdateRequest; +import kr.co.yigil.admin.interfaces.dto.request.AdminUpdateRequest; +import kr.co.yigil.admin.interfaces.dto.request.LoginRequest; +import kr.co.yigil.admin.interfaces.dto.response.AdminDetailInfoResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminInfoResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface AdminMapper { + + @Mapping(target = "email", source = "email") + @Mapping(target = "password", source = "password") + AdminCommand.LoginRequest toCommand(LoginRequest loginRequest); + +// @Mapping(target = "nickname", source = "nickname") +// @Mapping(target = "profileImageFile", source = "profileImageFile") +// @Mapping(target = "password", source = "password") +// AdminCommand.AdminUpdateRequest toCommand(AdminUpdateRequest updateRequest); + + @Mapping(target = "existingPassword", source = "existingPassword") + @Mapping(target = "newPassword", source = "newPassword") + AdminCommand.AdminPasswordUpdateRequest toCommand(AdminPasswordUpdateRequest updateRequest); + + @Mapping(target = "nickname", source = "nickname") + @Mapping(target = "profileUrl", source = "profileUrl") + AdminInfoResponse toResponse(AdminInfo.AdminInfoResponse info); + + @Mapping(target = "nickname", source = "nickname") + @Mapping(target = "profileUrl", source = "profileUrl") + @Mapping(target = "email", source = "email") + @Mapping(target = "password", source = "password") + AdminDetailInfoResponse toResponse(AdminInfo.AdminDetailInfoResponse info); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/mapper/AdminSignupMapper.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/mapper/AdminSignupMapper.java new file mode 100644 index 000000000..45b2a6d45 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/mapper/AdminSignupMapper.java @@ -0,0 +1,37 @@ +package kr.co.yigil.admin.interfaces.dto.mapper; + + +import java.time.format.DateTimeFormatter; +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpCommand; +import kr.co.yigil.admin.interfaces.dto.AdminSignUpInfoDto; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignupRequest; +import kr.co.yigil.admin.interfaces.dto.response.AdminSignUpsResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.data.domain.Page; + +@Mapper(componentModel = "spring") +public interface AdminSignupMapper { + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Mapping(target = "email", source = "email") + @Mapping(target = "nickname", source = "nickname") + AdminSignUpCommand.AdminSignUpRequest toCommand(AdminSignupRequest adminSignupRequest); + + default AdminSignUpsResponse toResponse(Page adminSignUps) { + Page signUpInfoDtoPage = adminSignUps.map(this::toAdminSignUpInfoDto); + return new AdminSignUpsResponse(signUpInfoDtoPage); + } + + @Mapping(target = "id", source = "id") + @Mapping(target = "email", source = "email") + @Mapping(target = "nickname", source = "nickname") + @Mapping(target = "requestDatetime", source = "createdAt", dateFormat = "yyyy-MM-dd HH:mm:ss") +// @Mapping(target = "requestDateTime", expression = "java(formatter.format(adminSignUp.getCreatedAt()))") + AdminSignUpInfoDto toAdminSignUpInfoDto(AdminSignUp adminSignUp); + + + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminPasswordUpdateRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminPasswordUpdateRequest.java new file mode 100644 index 000000000..690c5840a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminPasswordUpdateRequest.java @@ -0,0 +1,14 @@ +package kr.co.yigil.admin.interfaces.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AdminPasswordUpdateRequest { + private String existingPassword; + private String newPassword; + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminProfileImageUpdateRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminProfileImageUpdateRequest.java new file mode 100644 index 000000000..0554e46b8 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminProfileImageUpdateRequest.java @@ -0,0 +1,14 @@ +package kr.co.yigil.admin.interfaces.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AdminProfileImageUpdateRequest { + private MultipartFile profileImageFile; + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminSignUpListRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminSignUpListRequest.java new file mode 100644 index 000000000..f490bac58 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminSignUpListRequest.java @@ -0,0 +1,13 @@ +package kr.co.yigil.admin.interfaces.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AdminSignUpListRequest { + private int page; + private int dataCount; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminSignupRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminSignupRequest.java new file mode 100644 index 000000000..5e1f872a1 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminSignupRequest.java @@ -0,0 +1,13 @@ +package kr.co.yigil.admin.interfaces.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AdminSignupRequest { + private String email; + private String nickname; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/request/MemberUpdateRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminUpdateRequest.java similarity index 69% rename from backend/yigil-api/src/main/java/kr/co/yigil/member/dto/request/MemberUpdateRequest.java rename to backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminUpdateRequest.java index 8e2a83698..ca6eaaa58 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/request/MemberUpdateRequest.java +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/AdminUpdateRequest.java @@ -1,4 +1,4 @@ -package kr.co.yigil.member.dto.request; +package kr.co.yigil.admin.interfaces.dto.request; import lombok.AllArgsConstructor; import lombok.Data; @@ -6,9 +6,11 @@ import org.springframework.web.multipart.MultipartFile; @Data -@NoArgsConstructor @AllArgsConstructor -public class MemberUpdateRequest { +@NoArgsConstructor +public class AdminUpdateRequest { private String nickname; private MultipartFile profileImageFile; + private String password; + } diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/LoginRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/LoginRequest.java new file mode 100644 index 000000000..b88cbda25 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/LoginRequest.java @@ -0,0 +1,13 @@ +package kr.co.yigil.admin.interfaces.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + private String email; + private String password; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/SignUpAcceptRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/SignUpAcceptRequest.java new file mode 100644 index 000000000..28926b0d5 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/SignUpAcceptRequest.java @@ -0,0 +1,13 @@ +package kr.co.yigil.admin.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignUpAcceptRequest { + private List ids; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/SignUpRejectRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/SignUpRejectRequest.java new file mode 100644 index 000000000..f228ba069 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/request/SignUpRejectRequest.java @@ -0,0 +1,13 @@ +package kr.co.yigil.admin.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignUpRejectRequest { + private List ids; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminDetailInfoResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminDetailInfoResponse.java new file mode 100644 index 000000000..6399f396b --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminDetailInfoResponse.java @@ -0,0 +1,17 @@ +package kr.co.yigil.admin.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AdminDetailInfoResponse { + + private String nickname; + private String profileUrl; + private String email; + private String password; + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminInfoResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminInfoResponse.java new file mode 100644 index 000000000..0ffa6ee29 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminInfoResponse.java @@ -0,0 +1,15 @@ +package kr.co.yigil.admin.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AdminInfoResponse { + + private String nickname; + private String profileUrl; + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminPasswordUpdateResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminPasswordUpdateResponse.java new file mode 100644 index 000000000..c6c26e40a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminPasswordUpdateResponse.java @@ -0,0 +1,13 @@ +package kr.co.yigil.admin.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AdminPasswordUpdateResponse { + private String message; + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminProfileImageUpdateResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminProfileImageUpdateResponse.java new file mode 100644 index 000000000..b2593b090 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminProfileImageUpdateResponse.java @@ -0,0 +1,13 @@ +package kr.co.yigil.admin.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AdminProfileImageUpdateResponse { + + private String message; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminSignUpsResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminSignUpsResponse.java new file mode 100644 index 000000000..95ffb88e7 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminSignUpsResponse.java @@ -0,0 +1,15 @@ +package kr.co.yigil.admin.interfaces.dto.response; + +import kr.co.yigil.admin.interfaces.dto.AdminSignUpInfoDto; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AdminSignUpsResponse { + private Page adminSignUps; + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/dto/response/AddFavorResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminSignupResponse.java similarity index 65% rename from backend/yigil-api/src/main/java/kr/co/yigil/favor/dto/response/AddFavorResponse.java rename to backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminSignupResponse.java index 8c5ada0b4..e0ea32e7e 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/favor/dto/response/AddFavorResponse.java +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminSignupResponse.java @@ -1,12 +1,12 @@ -package kr.co.yigil.favor.dto.response; +package kr.co.yigil.admin.interfaces.dto.response; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data -@AllArgsConstructor @NoArgsConstructor -public class AddFavorResponse { +@AllArgsConstructor +public class AdminSignupResponse { private String message; } diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminUpdateResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminUpdateResponse.java new file mode 100644 index 000000000..bc54ed4bb --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/AdminUpdateResponse.java @@ -0,0 +1,12 @@ +package kr.co.yigil.admin.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AdminUpdateResponse { + private String message; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/response/CommentDeleteResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/SignUpAcceptResponse.java similarity index 65% rename from backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/response/CommentDeleteResponse.java rename to backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/SignUpAcceptResponse.java index 82372b8e6..c32f7c3c1 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/response/CommentDeleteResponse.java +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/SignUpAcceptResponse.java @@ -1,4 +1,4 @@ -package kr.co.yigil.comment.dto.response; +package kr.co.yigil.admin.interfaces.dto.response; import lombok.AllArgsConstructor; import lombok.Data; @@ -7,6 +7,6 @@ @Data @AllArgsConstructor @NoArgsConstructor -public class CommentDeleteResponse { +public class SignUpAcceptResponse { private String message; } diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/SignUpRejectResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/SignUpRejectResponse.java new file mode 100644 index 000000000..43e8cb865 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/admin/interfaces/dto/response/SignUpRejectResponse.java @@ -0,0 +1,12 @@ +package kr.co.yigil.admin.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SignUpRejectResponse { + private String message; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/CustomAuthenticationProvider.java b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/CustomAuthenticationProvider.java new file mode 100644 index 000000000..a0da8f4b2 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/CustomAuthenticationProvider.java @@ -0,0 +1,47 @@ +package kr.co.yigil.auth.application; + +import static kr.co.yigil.global.exception.ExceptionCode.ADMIN_NOT_FOUND; + +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.infrastructure.AdminRepository; +import kr.co.yigil.global.exception.AuthException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationProvider implements AuthenticationProvider { + + private final AdminRepository adminRepository; + private final PasswordService passwordService; + + @Override + public Authentication authenticate(Authentication authentication) { + String username = authentication.getName(); + String password = authentication.getCredentials().toString(); + + Admin admin = adminRepository.findByEmail(username) + .orElseThrow(() -> new AuthException(ADMIN_NOT_FOUND)); + + if (!passwordService.matches(password, admin.getPassword())) { + } + + List authorities = admin.getRoles().stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + return new UsernamePasswordAuthenticationToken(admin, null, authorities ); + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/CustomUserDetailsService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/CustomUserDetailsService.java new file mode 100644 index 000000000..45b691a75 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/CustomUserDetailsService.java @@ -0,0 +1,31 @@ +package kr.co.yigil.auth.application; + +import static kr.co.yigil.global.exception.ExceptionCode.ADMIN_NOT_FOUND; + +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.infrastructure.AdminRepository; +import kr.co.yigil.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final AdminRepository adminRepository; + private final PasswordEncoder passwordEncoder; + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return adminRepository.findByEmail(username) + .map(this::createUserDetails) + .orElseThrow(() -> new BadRequestException(ADMIN_NOT_FOUND)); + } + + private UserDetails createUserDetails(Admin admin) { + return admin; + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/JwtTokenProvider.java b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/JwtTokenProvider.java new file mode 100644 index 000000000..ebf03c176 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/JwtTokenProvider.java @@ -0,0 +1,104 @@ +package kr.co.yigil.auth.application; + +import static kr.co.yigil.global.exception.ExceptionCode.EXPIRED_JWT_TOKEN; +import static kr.co.yigil.global.exception.ExceptionCode.INVALID_JWT_TOKEN; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; +import kr.co.yigil.auth.dto.JwtToken; +import kr.co.yigil.global.exception.InvalidTokenException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenProvider { + private final Key key; + + public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public JwtToken generateToken(Authentication authentication) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + long now = (new Date()).getTime(); + + Date accessTokenExpiresIn = new Date(now + 86400000); + + String accessToken = Jwts.builder() + .setSubject(authentication.getName()) + .claim("auth", authorities) + .setExpiration(accessTokenExpiresIn) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + String refreshToken = Jwts.builder() + .setExpiration(new Date(now + 86400000)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + return new JwtToken("Bearer", accessToken, refreshToken); + } + + public Authentication getAuthentication(String accessToken) { + Claims claims = parseClaims(accessToken); + + if(claims.get("auth") == null) { + throw new InvalidTokenException(INVALID_JWT_TOKEN); + } + + Collection authorities = Arrays.stream(claims.get("auth").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .toList(); + + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return true; + } catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) { + throw new InvalidTokenException(INVALID_JWT_TOKEN); + } catch (ExpiredJwtException e) { + throw new InvalidTokenException(EXPIRED_JWT_TOKEN); + } + } + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/PasswordService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/PasswordService.java new file mode 100644 index 000000000..3fc0b11c5 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/application/PasswordService.java @@ -0,0 +1,16 @@ +package kr.co.yigil.auth.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PasswordService { + + private final PasswordEncoder passwordEncoder; + + public boolean matches(String password, String encodedPassword) { + return passwordEncoder.matches(password, encodedPassword); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/auth/dto/JwtToken.java b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/dto/JwtToken.java new file mode 100644 index 000000000..005dd53f7 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/dto/JwtToken.java @@ -0,0 +1,12 @@ +package kr.co.yigil.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class JwtToken { + private String grantType; + private String accessToken; + private String refreshToken; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/auth/filter/JwtAuthenticationFilter.java b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 000000000..43fc1f79a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,40 @@ +package kr.co.yigil.auth.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import kr.co.yigil.auth.application.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.GenericFilterBean; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends GenericFilterBean { + private final JwtTokenProvider jwtTokenProvider; + + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, + FilterChain filterChain) throws IOException, ServletException { + String token = resolveToken((HttpServletRequest) servletRequest); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(servletRequest, servletResponse); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/application/CommentFacade.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/application/CommentFacade.java new file mode 100644 index 000000000..8cf2853e5 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/application/CommentFacade.java @@ -0,0 +1,39 @@ +package kr.co.yigil.comment.application; + +import kr.co.yigil.admin.domain.admin.AdminService; +import kr.co.yigil.comment.domain.CommentInfo.ChildrenPageComments; +import kr.co.yigil.comment.domain.CommentInfo.CommentList; +import kr.co.yigil.comment.domain.CommentInfo.ParentPageComments; +import kr.co.yigil.comment.domain.CommentService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CommentFacade { + + private final CommentService commentService; + private final NotificationService notificationService; + private final AdminService adminService; + + public CommentList getComments(Long travelId, PageRequest pageRequest) { + return commentService.getComments(travelId, pageRequest); + } + + public ParentPageComments getParentComments(Long travelId, PageRequest pageRequest) { + return commentService.getParentComments(travelId, pageRequest); + } + public ChildrenPageComments getChildrenComments(Long travelId, PageRequest pageRequest) { + return commentService.getChildrenComments(travelId, pageRequest); + } + + public void deleteComment(Long commentId) { + Long memberId = commentService.deleteComment(commentId); + Long adminId = adminService.getAdminId(); + notificationService.sendNotification(NotificationType.COMMENT_DELETE, adminId, memberId); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentInfo.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentInfo.java new file mode 100644 index 000000000..36223318b --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentInfo.java @@ -0,0 +1,150 @@ +package kr.co.yigil.comment.domain; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +public class CommentInfo { + + + @Data + @AllArgsConstructor + public static class CommentList { + + private Page parents; + + public CommentList(List parents, Pageable pageable, long total) { + this.parents = new PageImpl<>(parents, pageable, total); + } + } + + @Data + @AllArgsConstructor + public static class CommentListUnit { + private Long id; + private String memberNickname; + private Long memberId; + private String content; + private LocalDateTime createdAt; + private List children; + + public CommentListUnit(Comment comment, List children) { + this.id = comment.getId(); + this.memberNickname = comment.getMember().getNickname(); + this.memberId = comment.getMember().getId(); + this.content = comment.getContent(); + this.createdAt = comment.getCreatedAt(); + this.children = children; + } + } + @Data + @AllArgsConstructor + public static class ReplyListUnit { + private Long id; + private String memberNickname; + private Long memberId; + private String content; + private LocalDateTime createdAt; + + public ReplyListUnit(Comment comment) { + this.id = comment.getId(); + this.memberNickname = comment.getMember().getNickname(); + this.memberId = comment.getMember().getId(); + this.content = comment.getContent(); + this.createdAt = comment.getCreatedAt(); + } + } + + + @Data + @AllArgsConstructor + public static class ParentPageComments { + + Page parentComments; + + public ParentPageComments(List parentComments, Pageable pageable, + long total) { + this.parentComments = new PageImpl<>(parentComments, pageable, total); + } + } + + @Data + public static class ChildrenPageComments { + + Page childrenComments; + + public ChildrenPageComments(List childrenComments, Pageable pageable, + long total) { + + this.childrenComments = new PageImpl<>(childrenComments, pageable, total); + + } + } + + @Data + @AllArgsConstructor + public static class ParentListInfo { + + private Long id; + private String memberNickname; + private Long memberId; + private String content; + private LocalDateTime createdAt; + private int childrenCount; + + public ParentListInfo(Comment comment, int childrenCount) { + this.id = comment.getId(); + this.memberNickname = comment.getMember().getNickname(); + this.memberId = comment.getMember().getId(); + this.content = comment.getContent(); + this.createdAt = comment.getCreatedAt(); + this.childrenCount = childrenCount; + } + } + + @Data + @AllArgsConstructor + public static class ChildrenListInfo { + + private Long id; + private String memberNickname; + private Long memberId; + private String content; + private LocalDateTime createdAt; + + public ChildrenListInfo(Comment comment) { + this.id = comment.getId(); + this.memberNickname = comment.getMember().getNickname(); + this.memberId = comment.getMember().getId(); + this.content = comment.getContent(); + this.createdAt = comment.getCreatedAt(); + } + } + + @Data + @AllArgsConstructor + public static class ParentDetailResponse { + + private Long id; + private String memberNickname; + private String memberId; + private String content; + private LocalDateTime createdAt; + private int childrenCount; + } + + @Data + @AllArgsConstructor + public static class ChildrenDetailResponse { + + private Long id; + private String memberNickname; + private String memberId; + private String content; + private LocalDateTime createdAt; + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentReader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentReader.java new file mode 100644 index 000000000..64bab6693 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentReader.java @@ -0,0 +1,19 @@ +package kr.co.yigil.comment.domain; + +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +public interface CommentReader { + + public int getCommentCount(Long travelId); + + Page getParentComments(Long travelId, PageRequest pageRequest); + + Page getChildrenComments(Long travelId, PageRequest pageRequest); + List getChildrenComments(Long travelId); + + int getChildrenCount(Long parentId); + + Comment getComment(Long commentId); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentService.java new file mode 100644 index 000000000..e74161f29 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentService.java @@ -0,0 +1,17 @@ +package kr.co.yigil.comment.domain; + +import kr.co.yigil.comment.domain.CommentInfo.ChildrenPageComments; +import kr.co.yigil.comment.domain.CommentInfo.CommentList; +import kr.co.yigil.comment.domain.CommentInfo.ParentPageComments; +import org.springframework.data.domain.PageRequest; + +public interface CommentService { + + ParentPageComments getParentComments(Long travelId, PageRequest pageRequest); + + ChildrenPageComments getChildrenComments(Long travelId, PageRequest pageRequest); + + Long deleteComment(Long commentId); + + CommentList getComments(Long travelId, PageRequest pageRequest); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentServiceImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentServiceImpl.java new file mode 100644 index 000000000..12b4a42c7 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentServiceImpl.java @@ -0,0 +1,77 @@ +package kr.co.yigil.comment.domain; + +import java.util.List; +import kr.co.yigil.comment.domain.CommentInfo.ChildrenListInfo; +import kr.co.yigil.comment.domain.CommentInfo.ChildrenPageComments; +import kr.co.yigil.comment.domain.CommentInfo.CommentList; +import kr.co.yigil.comment.domain.CommentInfo.CommentListUnit; +import kr.co.yigil.comment.domain.CommentInfo.ParentListInfo; +import kr.co.yigil.comment.domain.CommentInfo.ParentPageComments; +import kr.co.yigil.comment.domain.CommentInfo.ReplyListUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + + private final CommentReader commentReader; + private final CommentStore commentStore; + @Override + @Transactional(readOnly = true) + public ParentPageComments getParentComments(Long travelId, PageRequest pageRequest) { + Page comments = commentReader.getParentComments(travelId, pageRequest); + List parentListInfos = comments.getContent().stream().map( + comment -> { + int childrenCount = commentReader.getChildrenCount(comment.getId()); + return new ParentListInfo(comment, childrenCount); + } + ).toList(); + + return new ParentPageComments(parentListInfos, comments.getPageable(), comments.getTotalElements()); + } + + @Override + @Transactional(readOnly = true) + public ChildrenPageComments getChildrenComments(Long travelId, PageRequest pageRequest) { + Page comments = commentReader.getChildrenComments(travelId, pageRequest); + + List childrenListInfos = comments.getContent().stream() + .map(ChildrenListInfo::new) + .toList(); + + return new ChildrenPageComments(childrenListInfos, comments.getPageable(), comments.getTotalElements()); + } + + @Override + @Transactional + public Long deleteComment(Long commentId) { + Comment comment = commentReader.getComment(commentId); + commentStore.deleteComment(comment); + return comment.getMember().getId(); + } + + @Override + public CommentList getComments(Long travelId, PageRequest pageRequest) { + Page comments = commentReader.getParentComments(travelId, pageRequest); + + Pageable pageable = comments.getPageable(); + Long total = comments.getTotalElements(); + + List commentListUnits = comments.getContent().stream().map( + comment -> { + List replyListUnits = commentReader.getChildrenComments(comment.getId()).stream() + .map(ReplyListUnit::new) + .toList(); + return new CommentListUnit(comment, replyListUnits); + } + ).toList(); + + return new CommentList(commentListUnits, pageable, total); + + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentStore.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentStore.java new file mode 100644 index 000000000..9c9359d79 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/domain/CommentStore.java @@ -0,0 +1,6 @@ +package kr.co.yigil.comment.domain; + +public interface CommentStore { + + void deleteComment(Comment comment); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infrastructure/CommentReaderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infrastructure/CommentReaderImpl.java new file mode 100644 index 000000000..21e0c1522 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infrastructure/CommentReaderImpl.java @@ -0,0 +1,51 @@ +package kr.co.yigil.comment.infrastructure; + +import java.util.List; +import kr.co.yigil.comment.domain.Comment; +import kr.co.yigil.comment.domain.CommentReader; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CommentReaderImpl implements CommentReader { + + private final CommentRepository commentRepository; + + @Override + public int getCommentCount(Long travelId) { + return commentRepository.countAllByTravelIdAndIsDeletedFalse(travelId); + } + + @Override + public Page getParentComments(Long travelId, PageRequest pageRequest) { + return commentRepository.findAllAsPageImplByTravelIdAndParentIdIsNull(travelId, + pageRequest); + } + + @Override + public Page getChildrenComments(Long travelId, PageRequest pageRequest) { + return commentRepository.findAllByParentIdAndIsDeletedFalse(travelId, pageRequest); + } + + @Override + public List getChildrenComments(Long travelId) { + return commentRepository.findAllByParentIdAndIsDeletedFalse(travelId); + } + + @Override + public int getChildrenCount(Long parentId) { + return commentRepository.countByParentId(parentId); + } + + @Override + public Comment getComment(Long commentId) { + return commentRepository.findById(commentId).orElseThrow( + () -> new BadRequestException(ExceptionCode.NOT_FOUND_COMMENT_ID)); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infrastructure/CommentStoreImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infrastructure/CommentStoreImpl.java new file mode 100644 index 000000000..fa16e0ffa --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infrastructure/CommentStoreImpl.java @@ -0,0 +1,19 @@ +package kr.co.yigil.comment.infrastructure; + + +import kr.co.yigil.comment.domain.Comment; +import kr.co.yigil.comment.domain.CommentStore; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CommentStoreImpl implements CommentStore { + + private final CommentRepository commentRepository; + + @Override + public void deleteComment(Comment comment) { + commentRepository.delete(comment); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infterfaces/controller/CommentApiController.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infterfaces/controller/CommentApiController.java new file mode 100644 index 000000000..d0bedf819 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infterfaces/controller/CommentApiController.java @@ -0,0 +1,78 @@ +package kr.co.yigil.comment.infterfaces.controller; + +import kr.co.yigil.comment.application.CommentFacade; +import kr.co.yigil.comment.domain.CommentInfo; +import kr.co.yigil.comment.domain.CommentInfo.CommentList; +import kr.co.yigil.comment.infterfaces.dto.CommentDto.ChildrenCommentsResponse; +import kr.co.yigil.comment.infterfaces.dto.CommentDto.CommentsResponse; +import kr.co.yigil.comment.infterfaces.dto.CommentDto.ParentCommentsResponse; +import kr.co.yigil.comment.infterfaces.dto.mapper.CommentMapper; +import kr.co.yigil.global.SortBy; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/comments") +public class CommentApiController { + + private final CommentFacade commentFacade; + private final CommentMapper commentMapper; + + @GetMapping("/{travel_id}") + + public ResponseEntity getCommentList( + @PathVariable("travel_id") Long travelId, + @PageableDefault(size = 5, page = 1) Pageable pageable + ) { + var pageRequest = PageRequest.of(pageable.getPageNumber() - 1, pageable.getPageSize() + , Sort.by(Direction.DESC, SortBy.CREATED_AT.getValue())); + CommentList info = commentFacade.getComments(travelId, + pageRequest); + CommentsResponse response = commentMapper.of(info); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/{travel_id}/parents") + public ResponseEntity getParentCommentList( + @PathVariable("travel_id") Long travelId, + @PageableDefault(size = 5, page = 1) Pageable pageable + ) { + var pageRequest = PageRequest.of(pageable.getPageNumber() - 1, pageable.getPageSize() + , Sort.by(Direction.DESC, SortBy.CREATED_AT.getValue())); + CommentInfo.ParentPageComments info = commentFacade.getParentComments(travelId, + pageRequest); + ParentCommentsResponse response = commentMapper.of(info); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/{parent_id}/children") + public ResponseEntity getChildrenCommentList( + @PathVariable("parent_id") Long parentId, + @PageableDefault(size = 5, page = 1) Pageable pageable + ) { + var pageRequest = PageRequest.of(pageable.getPageNumber() - 1, pageable.getPageSize() + , Sort.by(Direction.DESC, SortBy.CREATED_AT.getValue())); + CommentInfo.ChildrenPageComments info = commentFacade.getChildrenComments(parentId, + pageRequest); + ChildrenCommentsResponse response = commentMapper.of(info); + return ResponseEntity.ok().body(response); + } + + @DeleteMapping("/{comment_id}") + public ResponseEntity deleteComment(@PathVariable("comment_id") Long commentId) { + commentFacade.deleteComment(commentId); + return ResponseEntity.ok().body("댓글 삭제 성공"); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infterfaces/dto/CommentDto.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infterfaces/dto/CommentDto.java new file mode 100644 index 000000000..aca2a93cf --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infterfaces/dto/CommentDto.java @@ -0,0 +1,93 @@ +package kr.co.yigil.comment.infterfaces.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.data.domain.Page; + +public class CommentDto { + + @Data + @AllArgsConstructor + public static class CommentsResponse{ + Page comments; + } + + @Data + @AllArgsConstructor + public static class CommentUnitDto{ + private Long id; + private String memberNickname; + private Long memberId; + private String content; + private LocalDateTime createdAt; + private List children; + } + + @Data + @AllArgsConstructor + public static class ReplyUnitDto { + private Long id; + private String memberNickname; + private Long memberId; + private String content; + private LocalDateTime createdAt; + } + + @Data + @AllArgsConstructor + public static class ParentCommentsResponse{ + Page parentComments; + } + + @Data + @AllArgsConstructor + public static class ChildrenCommentsResponse{ + Page childrenComments; + } + + @Data + @AllArgsConstructor + public static class ParentCommentsInfo{ + private Long id; + private String memberNickname; + private Long memberId; + private String content; + private LocalDateTime createdAt; + private int childrenCount; + } + + @Data + @AllArgsConstructor + public static class ChildrenCommentsInfo{ + private Long id; + private String memberNickname; + private Long memberId; + private String content; + private LocalDateTime createdAt; + } + + @Data + @AllArgsConstructor + public static class ParentCommentDetailResponse{ + private Long id; + private String memberNickname; + private String memberId; + private String content; + private LocalDateTime createdAt; + private int childrenCount; + } + + @Data + @AllArgsConstructor + public static class ChildrenCommentDetailResponse{ + private Long id; + private String memberNickname; + private String memberId; + private String content; + private LocalDateTime createdAt; + } + + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infterfaces/dto/mapper/CommentMapper.java b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infterfaces/dto/mapper/CommentMapper.java new file mode 100644 index 000000000..be62b1abe --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/comment/infterfaces/dto/mapper/CommentMapper.java @@ -0,0 +1,65 @@ +package kr.co.yigil.comment.infterfaces.dto.mapper; + +import kr.co.yigil.comment.domain.CommentInfo; +import kr.co.yigil.comment.domain.CommentInfo.ChildrenPageComments; +import kr.co.yigil.comment.domain.CommentInfo.ParentPageComments; +import kr.co.yigil.comment.infterfaces.dto.CommentDto; +import kr.co.yigil.comment.infterfaces.dto.CommentDto.ChildrenCommentsResponse; +import kr.co.yigil.comment.infterfaces.dto.CommentDto.ParentCommentsResponse; +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface CommentMapper { + + default CommentDto.CommentsResponse of(CommentInfo.CommentList info){ + return new CommentDto.CommentsResponse( + info.getParents().map(this::of) + ); + } + + @Mapping(target = "children", source= "children") + CommentDto.CommentUnitDto of (CommentInfo.CommentListUnit info); + CommentDto.ReplyUnitDto of (CommentInfo.ReplyListUnit info); + + default ParentCommentsResponse of(ParentPageComments info){ + return new ParentCommentsResponse( + info.getParentComments().map(this::ofParentCommentInfo) + ); + } + + default ChildrenCommentsResponse of(ChildrenPageComments info){ + return new ChildrenCommentsResponse( + info.getChildrenComments().map(this::ofChildrenCommentInfo) + ); + } + + default CommentDto.ParentCommentsInfo ofParentCommentInfo(CommentInfo.ParentListInfo info){ + return new CommentDto.ParentCommentsInfo( + info.getId(), + info.getMemberNickname(), + info.getMemberId(), + info.getContent(), + info.getCreatedAt(), + info.getChildrenCount() + ); + } + + default CommentDto.ChildrenCommentsInfo ofChildrenCommentInfo(CommentInfo.ChildrenListInfo info){ + return new CommentDto.ChildrenCommentsInfo( + info.getId(), + info.getMemberNickname(), + info.getMemberId(), + info.getContent(), + info.getCreatedAt() + ); + } + + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/email/EmailEventType.java b/backend/yigil-admin/src/main/java/kr/co/yigil/email/EmailEventType.java new file mode 100644 index 000000000..3d7b8bc98 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/email/EmailEventType.java @@ -0,0 +1,16 @@ +package kr.co.yigil.email; + +public enum EmailEventType { + ADMIN_SIGN_UP_ACCEPT { + @Override + public String toString() { + return "accept"; + } + }, + ADMIN_SIGN_UP_REJECT { + @Override + public String toString() { + return "reject"; + } + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/email/EmailSendEvent.java b/backend/yigil-admin/src/main/java/kr/co/yigil/email/EmailSendEvent.java new file mode 100644 index 000000000..1e8149f8b --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/email/EmailSendEvent.java @@ -0,0 +1,22 @@ +package kr.co.yigil.email; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class EmailSendEvent extends ApplicationEvent { + private final String to; + private final String subject; + private final String message; + private final String key; + private final EmailEventType type; + + public EmailSendEvent(Object source, String email, String subject, String message, String key, EmailEventType type) { + super(source); + to = email; + this.subject = subject; + this.message = message; + this.key = key; + this.type = type; + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/email/EmailSendEventListener.java b/backend/yigil-admin/src/main/java/kr/co/yigil/email/EmailSendEventListener.java new file mode 100644 index 000000000..2b76ee018 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/email/EmailSendEventListener.java @@ -0,0 +1,48 @@ +package kr.co.yigil.email; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.global.exception.MailException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +@Service +@RequiredArgsConstructor +public class EmailSendEventListener { + + private final JavaMailSender javaMailSender; + private final SpringTemplateEngine templateEngine; + + @Async + @EventListener + public void handleAcceptEmailSend(EmailSendEvent event) { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + try { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); + mimeMessageHelper.setTo(event.getTo()); + mimeMessageHelper.setSubject(event.getSubject()); + mimeMessageHelper.setText(setContext(event), true); + javaMailSender.send(mimeMessage); + } catch (MessagingException e) { + throw new MailException(ExceptionCode.SMTP_SERVER_ERROR); + } + } + + private String setContext(EmailSendEvent event) { + Context context = new Context(); + if(event.getType() == EmailEventType.ADMIN_SIGN_UP_ACCEPT) + context.setVariable("password", event.getKey()); + + return templateEngine.process(event.getType().toString(), context); + } + + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/favor/domain/FavorReader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/favor/domain/FavorReader.java new file mode 100644 index 000000000..d0ce7e189 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/favor/domain/FavorReader.java @@ -0,0 +1,7 @@ +package kr.co.yigil.favor.domain; + +public interface FavorReader { + + int getFavorCount(Long favorId); + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/favor/infrastructure/FavorReaderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/favor/infrastructure/FavorReaderImpl.java new file mode 100644 index 000000000..08b56cb4d --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/favor/infrastructure/FavorReaderImpl.java @@ -0,0 +1,17 @@ +package kr.co.yigil.favor.infrastructure; + +import kr.co.yigil.favor.domain.FavorReader; +import kr.co.yigil.favor.domain.repository.FavorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FavorReaderImpl implements FavorReader { + + private final FavorRepository favorRepository; + + public int getFavorCount(Long travelId) { + return favorRepository.countByTravelId(travelId); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/file/domain/FileUploadEvent.java b/backend/yigil-admin/src/main/java/kr/co/yigil/file/domain/FileUploadEvent.java new file mode 100644 index 000000000..be1c5cf8c --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/file/domain/FileUploadEvent.java @@ -0,0 +1,50 @@ +package kr.co.yigil.file.domain; + +import java.util.function.Consumer; +import kr.co.yigil.file.FileType; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.global.exception.FileException; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; +import org.springframework.web.multipart.MultipartFile; + +@Getter +public class FileUploadEvent extends ApplicationEvent { + + private static final long MAX_IMAGE_SIZE = 10485760; + private static final long MAX_VIDEO_SIZE = MAX_IMAGE_SIZE * 5; + + private final MultipartFile file; + private final kr.co.yigil.file.FileType fileType; + private final Consumer callback; + + public FileUploadEvent(Object source, MultipartFile file, Consumer callback) { + super(source); + this.file = file; + this.callback = callback; + fileType = determineFileType(file); + validateFileSize(fileType, file.getSize()); + } + + private void validateFileSize(kr.co.yigil.file.FileType fileType, long size) { + long maxSize = fileType == kr.co.yigil.file.FileType.IMAGE ? MAX_IMAGE_SIZE : MAX_VIDEO_SIZE; + if (size > maxSize) { + throw new FileException(ExceptionCode.EXCEED_FILE_CAPACITY); + } + } + + private kr.co.yigil.file.FileType determineFileType(MultipartFile file) { + if (file == null) throw new FileException(ExceptionCode.EMPTY_FILE); + + if(file.getContentType().startsWith("image/")) { + return kr.co.yigil.file.FileType.IMAGE; + } + + if(file.getContentType().startsWith("video/")) { + return FileType.VIDEO; + } + + throw new FileException(ExceptionCode.INVALID_FILE_TYPE); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/file/domain/FileUploadEventListener.java b/backend/yigil-admin/src/main/java/kr/co/yigil/file/domain/FileUploadEventListener.java new file mode 100644 index 000000000..148bb5b16 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/file/domain/FileUploadEventListener.java @@ -0,0 +1,51 @@ +package kr.co.yigil.file.domain; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.ObjectMetadata; +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import kr.co.yigil.file.FileType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FileUploadEventListener { + + private final AmazonS3Client amazonS3Client; + private final String bucketName = "cdn.yigil.co.kr"; + + @Async + @EventListener + public Future handleFileUpload(FileUploadEvent event) throws IOException { + MultipartFile file = event.getFile(); + FileType fileType = event.getFileType(); + String fileName = generateUniqueFileName(file.getOriginalFilename()); + String s3Path = getS3Path(fileType, fileName); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + metadata.setContentDisposition("inline"); + amazonS3Client.putObject(bucketName, s3Path, file.getInputStream(), metadata); + + event.getCallback().accept(s3Path); + return CompletableFuture.completedFuture(s3Path); + } + + private String getS3Path(kr.co.yigil.file.FileType fileType, String fileName) { + String url = fileType == FileType.IMAGE ? "images/" : "videos/"; + return url + fileName; + } + + private String generateUniqueFileName(String originalFilename) { + return UUID.randomUUID() + "_" + originalFilename; + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/file/domain/FileUploader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/file/domain/FileUploader.java new file mode 100644 index 000000000..957defb48 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/file/domain/FileUploader.java @@ -0,0 +1,10 @@ +package kr.co.yigil.file.domain; + +import kr.co.yigil.file.AttachFile; +import org.springframework.web.multipart.MultipartFile; + +public interface FileUploader { + + AttachFile upload(MultipartFile file); + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/file/infrastructure/FileUploaderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/file/infrastructure/FileUploaderImpl.java new file mode 100644 index 000000000..a8e1ca9f1 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/file/infrastructure/FileUploaderImpl.java @@ -0,0 +1,46 @@ +package kr.co.yigil.file.infrastructure; + +import java.util.concurrent.CompletableFuture; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.FileType; +import kr.co.yigil.file.domain.FileUploadEvent; +import kr.co.yigil.file.domain.FileUploader; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.global.exception.FileException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +@Component +@RequiredArgsConstructor +public class FileUploaderImpl implements FileUploader { + + private final ApplicationEventPublisher eventPublisher; + + @Override + public AttachFile upload(MultipartFile file) { + CompletableFuture fileUploadResult = new CompletableFuture<>(); + + FileUploadEvent event = new FileUploadEvent(this, file, fileUploadResult::complete); + eventPublisher.publishEvent(event); + + String fileUrl = fileUploadResult.join(); + FileType fileType = determineFileType(file); + + return new AttachFile(fileType, fileUrl, file.getOriginalFilename(), file.getSize()); + } + + private FileType determineFileType(MultipartFile file) { + if (file == null) throw new FileException(ExceptionCode.EMPTY_FILE); + + if(file.getContentType().startsWith("image/")) { + return FileType.IMAGE; + } + + if(file.getContentType().startsWith("video/")) { + return FileType.VIDEO; + } + + throw new FileException(ExceptionCode.INVALID_FILE_TYPE); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/AsyncConfig.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/AsyncConfig.java new file mode 100644 index 000000000..43800683a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/AsyncConfig.java @@ -0,0 +1,10 @@ +package kr.co.yigil.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/PasswordEncoderConfig.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/PasswordEncoderConfig.java new file mode 100644 index 000000000..7955e954f --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package kr.co.yigil.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/QueryDslConfig.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/QueryDslConfig.java new file mode 100644 index 000000000..1ed960e9a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package kr.co.yigil.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class QueryDslConfig { + + private final EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory(){ + return new JPAQueryFactory(em); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/S3Config.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/S3Config.java new file mode 100644 index 000000000..6f451a046 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/S3Config.java @@ -0,0 +1,28 @@ +package kr.co.yigil.global.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/SecurityConfig.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/SecurityConfig.java new file mode 100644 index 000000000..9261e75ac --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/SecurityConfig.java @@ -0,0 +1,67 @@ +package kr.co.yigil.global.config; + +import java.util.List; +import kr.co.yigil.auth.application.CustomAuthenticationProvider; +import kr.co.yigil.auth.application.CustomUserDetailsService; +import kr.co.yigil.auth.application.JwtTokenProvider; +import kr.co.yigil.auth.filter.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtTokenProvider jwtTokenProvider; + private final CustomAuthenticationProvider customAuthenticationProvider; + private final CustomUserDetailsService customUserDetailsService; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(corsCustomizer -> corsCustomizer.configurationSource(request -> { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:5173", "https://yigil.co.kr", "https://admin.yigil.co.kr")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + return config; + })) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(customAuthenticationProvider) + .userDetailsService(customUserDetailsService) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/admins/login").permitAll() + .requestMatchers("/api/v1/admins/signup").permitAll() + .requestMatchers("/api/v1/admins/test").permitAll() + .anyRequest().authenticated() + ) + .httpBasic(Customizer.withDefaults()) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManagerBean(HttpSecurity http) throws Exception { + return http.getSharedObject(AuthenticationManagerBuilder.class).build(); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/datasource/DataSourceType.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/datasource/DataSourceType.java new file mode 100644 index 000000000..90988821a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/datasource/DataSourceType.java @@ -0,0 +1,5 @@ +package kr.co.yigil.global.config.datasource; + +public enum DataSourceType { + MASTER, SLAVE +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/datasource/MasterDataSourceConfig.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/datasource/MasterDataSourceConfig.java new file mode 100644 index 000000000..3ff7c1247 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/datasource/MasterDataSourceConfig.java @@ -0,0 +1,22 @@ +package kr.co.yigil.global.config.datasource; + +import com.zaxxer.hikari.HikariDataSource; +import javax.sql.DataSource; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class MasterDataSourceConfig { + + @Primary + @Bean(name = "masterDataSource") + @ConfigurationProperties(prefix = "spring.datasource.master.hikari") + public DataSource masterDataSource() { + return DataSourceBuilder.create() + .type(HikariDataSource.class) + .build(); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/datasource/SlaveDataSourceConfig.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/datasource/SlaveDataSourceConfig.java new file mode 100644 index 000000000..f0bba18c3 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/datasource/SlaveDataSourceConfig.java @@ -0,0 +1,20 @@ +package kr.co.yigil.global.config.datasource; + +import com.zaxxer.hikari.HikariDataSource; +import javax.sql.DataSource; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SlaveDataSourceConfig { + + @Bean(name= "slaveDataSource") + @ConfigurationProperties(prefix = "spring.datasource.slave.hikari") + public DataSource slaveDataSource() { + return DataSourceBuilder.create() + .type(HikariDataSource.class) + .build(); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/replication/ReplicationRoutingDataSource.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/replication/ReplicationRoutingDataSource.java new file mode 100644 index 000000000..b698e8132 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/replication/ReplicationRoutingDataSource.java @@ -0,0 +1,15 @@ +package kr.co.yigil.global.config.replication; + +import kr.co.yigil.global.config.datasource.DataSourceType; +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +public class ReplicationRoutingDataSource extends AbstractRoutingDataSource { + + + @Override + protected Object determineCurrentLookupKey() { + return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? + DataSourceType.SLAVE : DataSourceType.MASTER; + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/replication/RoutingDataSourceConfig.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/replication/RoutingDataSourceConfig.java new file mode 100644 index 000000000..c800392db --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/config/replication/RoutingDataSourceConfig.java @@ -0,0 +1,32 @@ +package kr.co.yigil.global.config.replication; + +import java.util.Map; +import javax.sql.DataSource; +import kr.co.yigil.global.config.datasource.DataSourceType; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; + +@Configuration +public class RoutingDataSourceConfig { + + @Bean(name= "routingDataSource") + public DataSource routingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource, + @Qualifier("slaveDataSource") DataSource slaveDataSource) { + ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource(); + + routingDataSource.setTargetDataSources(Map.of( + DataSourceType.MASTER, masterDataSource, + DataSourceType.SLAVE, slaveDataSource + )); + routingDataSource.setDefaultTargetDataSource(masterDataSource); + + return routingDataSource; + } + + @Bean(name = "dataSource") + public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) { + return new LazyConnectionDataSourceProxy(routingDataSource); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/AuthException.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/AuthException.java new file mode 100644 index 000000000..1e21b518d --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/AuthException.java @@ -0,0 +1,16 @@ +package kr.co.yigil.global.exception; + +import lombok.Getter; + +@Getter +public class AuthException extends RuntimeException{ + + private final int code; + private final String message; + + public AuthException(final ExceptionCode exceptionCode) { + this.code = exceptionCode.getCode(); + this.message = exceptionCode.getMessage(); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/BadRequestException.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/BadRequestException.java new file mode 100644 index 000000000..41d949db7 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/BadRequestException.java @@ -0,0 +1,15 @@ +package kr.co.yigil.global.exception; + +import lombok.Getter; + +@Getter +public class BadRequestException extends RuntimeException { + + private final int code; + private final String message; + + public BadRequestException(final ExceptionCode exceptionCode) { + this.code = exceptionCode.getCode(); + this.message = exceptionCode.getMessage(); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/ExceptionCode.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/ExceptionCode.java new file mode 100644 index 000000000..fd101edbd --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/ExceptionCode.java @@ -0,0 +1,42 @@ +package kr.co.yigil.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum ExceptionCode { + + INVALID_REQUEST(1000, "올바르지 않은 요청입니다."), + NOT_FOUND_MEMBER_ID(1001, "사용자를 찾을 수 없습니다."), + NOT_FOUND_SPOT_ID(1021, "해당하는 spot이 없습니다"), + NOT_FOUND_COURSE_ID(1031, "해당하는 course가 없습니다"), + NOT_FOUND_TRAVEL_ID(1041, "해당하는 travel이 없습니다"), + NOT_FOUND_COMMENT_ID(1051, "해당하는 comment가 없습니다"), + NOT_FOUND_PLACE_ID(1061, "해당하는 place가 없습니다"), + NOT_FOUND_REGION_ID(1081, "해당하는 region이 없습니다"), + ADMIN_NOT_FOUND(1101, "관리자 정보를 찾을 수 없습니다."), + ADMIN_PASSWORD_DOES_NOT_MATCH(1102, "비밀번호가 맞지 않습니다."), + ADMIN_ALREADY_EXISTED(1201, "이미 존재하는 이메일 또는 닉네임입니다."), + ADMIN_SIGNUP_REQUEST_NOT_FOUND(1202, "관리자 가입 요청 정보를 찾을 수 없습니다."), + + NOTICE_NOT_FOUND(3001, "공지사항을 찾을 수 없습니다."), + + ALREADY_BANNED(4001, "이미 정지된 사용자입니다."), + ALREADY_UNBANNED(4002, "이미 정지 해제된 사용자입니다."), + + EMPTY_FILE(5001, "업로드한 파일이 비어있습니다."), + INVALID_FILE_TYPE(5002, "지원하지 않는 형식의 파일입니다."), + EXCEED_FILE_CAPACITY(5003, "업로드 가능한 파일 용량을 초과했습니다."), + + INVALID_JWT_TOKEN(9101, "올바르지 않은 형식의 JWT 토큰입니다."), + EXPIRED_JWT_TOKEN(9102, "만료된 JWT 토큰입니다."), + INVALID_AUTHORITY(9201, "해당 요청에 대한 접근 권한이 없습니다."), + + SMTP_SERVER_ERROR(9998, "이메일 전송 중 에러가 발생했습니다."), + INTERNAL_SERVER_ERROR(9999, "서버 에러가 발생했습니다.") + ; + + private final int code; + private final String message; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/ExceptionResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/ExceptionResponse.java new file mode 100644 index 000000000..60ce8ea1c --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/ExceptionResponse.java @@ -0,0 +1,11 @@ +package kr.co.yigil.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ExceptionResponse { + private final int code; + private final String message; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/FileException.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/FileException.java new file mode 100644 index 000000000..e7d4647bc --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/FileException.java @@ -0,0 +1,12 @@ +package kr.co.yigil.global.exception; + +import lombok.Getter; + +@Getter +public class FileException extends BadRequestException { + + public FileException(final ExceptionCode excepionCode) { + super(excepionCode); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/GlobalExceptionHandler.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..f5d3179b5 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,75 @@ +package kr.co.yigil.global.exception; + +import static kr.co.yigil.global.exception.ExceptionCode.INTERNAL_SERVER_ERROR; +import static kr.co.yigil.global.exception.ExceptionCode.INVALID_REQUEST; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@RestControllerAdvice +@RequiredArgsConstructor +@Slf4j +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + private final HttpServletRequest request; + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + @NotNull final MethodArgumentNotValidException e, + @NotNull final HttpHeaders headers, + @NotNull final HttpStatusCode status, + @NotNull final WebRequest request + ) { + log.warn(e.getMessage(), e); + + final String errorMessage = Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage(); + return ResponseEntity.badRequest() + .body(new ExceptionResponse(INVALID_REQUEST.getCode(), errorMessage)); + } + + @ExceptionHandler(AuthException.class) + public ResponseEntity handleAuthException(final AuthException e) { + log.warn(e.getMessage(), e); + + return ResponseEntity.badRequest() + .body(new ExceptionResponse(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequestException(final BadRequestException e) { + log.warn(e.getMessage(), e); + + return ResponseEntity.badRequest() + .body(new ExceptionResponse(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MailException.class) + public ResponseEntity handleMailException(final MailException e) { + log.error(e.getMessage(), e); + + return ResponseEntity.internalServerError() + .body(new ExceptionResponse(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(final Exception e) { + log.error(e.getMessage(), e); + + return ResponseEntity.internalServerError() + .body(new ExceptionResponse(INTERNAL_SERVER_ERROR.getCode(), + INTERNAL_SERVER_ERROR.getMessage())); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/InvalidTokenException.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/InvalidTokenException.java new file mode 100644 index 000000000..2f0c2ca4e --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/InvalidTokenException.java @@ -0,0 +1,11 @@ +package kr.co.yigil.global.exception; + +import lombok.Getter; + +@Getter +public class InvalidTokenException extends AuthException { + + public InvalidTokenException(final ExceptionCode exceptionCode) { + super(exceptionCode); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/MailException.java b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/MailException.java new file mode 100644 index 000000000..d374044ba --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/global/exception/MailException.java @@ -0,0 +1,15 @@ +package kr.co.yigil.global.exception; + +import lombok.Getter; + +@Getter +public class MailException extends RuntimeException{ + + private final int code; + private final String message; + + public MailException(final ExceptionCode exceptionCode) { + this.code = exceptionCode.getCode(); + this.message = exceptionCode.getMessage(); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/application/MemberFacade.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/application/MemberFacade.java new file mode 100644 index 000000000..3f2b674c0 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/application/MemberFacade.java @@ -0,0 +1,28 @@ +package kr.co.yigil.member.application; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberService; +import kr.co.yigil.member.interfaces.dto.request.MemberBanRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberFacade { + + private final MemberService memberService; + + public Page getMemberPage(Pageable pageable) { + return memberService.getMemberPage(pageable); + } + + public void banMembers(MemberBanRequest request) { + memberService.banMembers(request); + } + + public void unbanMembers(MemberBanRequest request) { + memberService.unbanMembers(request); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberCommand.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberCommand.java new file mode 100644 index 000000000..11a2a48a8 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberCommand.java @@ -0,0 +1,23 @@ +package kr.co.yigil.member.domain; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.web.multipart.MultipartFile; + +public class MemberCommand { + + @Getter + @Builder + @ToString + public static class MemberUpdateRequest { + + private String nickname; + private String ages; + private String gender; + private MultipartFile profileImageFile; + private List favoriteRegionIds; + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberInfo.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberInfo.java new file mode 100644 index 000000000..979e36102 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberInfo.java @@ -0,0 +1,73 @@ +package kr.co.yigil.member.domain; + +import java.util.List; +import kr.co.yigil.follow.domain.FollowCount; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +import lombok.Getter; +import lombok.ToString; + +public class MemberInfo { + /** + * 멤버 정보 조회 응답 + */ + @Getter + @ToString + public static class Main { + + private final Long memberId; + private final String email; + private final String nickname; + private final String profileImageUrl; + private final List favoriteRegionIds; + private final int followingCount; + private final int followerCount; + + public Main(Member member, FollowCount followCount) { + this.memberId = member.getId(); + this.email = member.getEmail(); + this.nickname = member.getNickname(); + this.profileImageUrl = member.getProfileImageUrl(); + this.followingCount = followCount.getFollowingCount(); + this.followerCount = followCount.getFollowerCount(); + this.favoriteRegionIds = member.getFavoriteRegionIds(); + } + } + + @Getter + @ToString + public static class PlaceInfo { + + private final String placeName; + private final String placeAddress; + private final String mapStaticImageUrl; + private final String placeImageUrl; + + public PlaceInfo(Place place) { + this.placeName = place.getName(); + this.placeAddress = place.getAddress(); + this.mapStaticImageUrl = place.getMapStaticImageFile().getFileUrl(); + this.placeImageUrl = place.getImageFile().getFileUrl(); + } + } + + @Getter + @ToString + public static class MemberUpdateResponse { + private final String message; + + public MemberUpdateResponse(String message) { + this.message = message; + } + } + + + @Getter + @ToString + public static class MemberDeleteResponse { + private final String message; + public MemberDeleteResponse(String message) { + this.message = message; + } + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberReader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberReader.java new file mode 100644 index 000000000..4d10ab59f --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberReader.java @@ -0,0 +1,22 @@ +package kr.co.yigil.member.domain; + +import java.util.Optional; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface MemberReader { + + Member getMember(Long memberId); + + Optional findMemberBySocialLoginIdAndSocialLoginType(String socialLoginId, SocialLoginType socialLoginType); + + Optional findMemberByEmailAndSocialLoginType(String email, SocialLoginType socialLoginType); + + void validateMember(Long memberId); + + Member getMemberRegardlessOfStatus(Long memberId); + + Page getMemberPageRegardlessOfStatus(Pageable pageable); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberService.java new file mode 100644 index 000000000..93bbae96a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberService.java @@ -0,0 +1,16 @@ +package kr.co.yigil.member.domain; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.interfaces.dto.request.MemberBanRequest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + + +public interface MemberService { + + Page getMemberPage(Pageable pageable); + + void banMembers(MemberBanRequest request); + + void unbanMembers(MemberBanRequest request); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberServiceImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberServiceImpl.java new file mode 100644 index 000000000..8215cfe69 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberServiceImpl.java @@ -0,0 +1,60 @@ +package kr.co.yigil.member.domain; + +import java.util.List; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.MemberStatus; +import kr.co.yigil.member.interfaces.dto.request.MemberBanRequest; +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; + +@Service +@RequiredArgsConstructor +public class MemberServiceImpl implements MemberService { + private final MemberReader memberReader; + private final MemberStore memberStore; + + @Override + @Transactional(readOnly = true) + public Page getMemberPage(Pageable pageable) { + return memberReader.getMemberPageRegardlessOfStatus(pageable); + } + + @Override + @Transactional + public void banMembers(MemberBanRequest request) { + List memberIds = request.getIds(); + for (Long memberId : memberIds) { + checkAlreadyBannedMember(memberId); + memberStore.banMember(memberId); + } + } + + private void checkAlreadyBannedMember(Long memberId) { + if (memberReader.getMemberRegardlessOfStatus(memberId).getStatus()== MemberStatus.BANNED) { + throw new BadRequestException(ExceptionCode.ALREADY_BANNED); + } + } + + @Override + @Transactional + public void unbanMembers(MemberBanRequest request) { + List memberIds = request.getIds(); +for (Long memberId : memberIds) { + checkAlreadyUnbannedMember(memberId); + memberStore.unbanMember(memberId); + } + + } + + private void checkAlreadyUnbannedMember(Long memberId) { + if (memberReader.getMemberRegardlessOfStatus(memberId).getStatus()== MemberStatus.ACTIVE) { + throw new BadRequestException(ExceptionCode.ALREADY_UNBANNED); + } + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberStore.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberStore.java new file mode 100644 index 000000000..0f7657f33 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/domain/MemberStore.java @@ -0,0 +1,14 @@ +package kr.co.yigil.member.domain; + +import kr.co.yigil.member.Member; + +public interface MemberStore { + + public void deleteMember(Long memberId); + + public Member save(Member member); + + void banMember(Long memberId); + + void unbanMember(Long memberId); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/infrastructure/MemberReaderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/infrastructure/MemberReaderImpl.java new file mode 100644 index 000000000..80ca75264 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/infrastructure/MemberReaderImpl.java @@ -0,0 +1,57 @@ +package kr.co.yigil.member.infrastructure; + +import static kr.co.yigil.global.exception.ExceptionCode.NOT_FOUND_MEMBER_ID; + +import java.util.Optional; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberReaderImpl implements MemberReader { + + private final MemberRepository memberRepository; + + @Override + public Member getMember(Long memberId) { + return memberRepository.findById(memberId).orElseThrow( + () -> new BadRequestException(NOT_FOUND_MEMBER_ID) + ); + } + + @Override + public Member getMemberRegardlessOfStatus(Long memberId) { + return memberRepository.findByIdRegardlessOfStatus(memberId) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_MEMBER_ID)); + } + + @Override + public Optional findMemberBySocialLoginIdAndSocialLoginType(String socialLoginId, + SocialLoginType socialLoginType) { + return memberRepository.findMemberBySocialLoginIdAndSocialLoginType(socialLoginId, + socialLoginType); + } + + @Override + public Optional findMemberByEmailAndSocialLoginType(String email, + SocialLoginType socialLoginType) { + return memberRepository.findMemberByEmailAndSocialLoginType(email, socialLoginType); + } + + public void validateMember(Long memberId) { + if (!memberRepository.existsById(memberId)) { + throw new BadRequestException(NOT_FOUND_MEMBER_ID); + } + } + + public Page getMemberPageRegardlessOfStatus(Pageable pageable) { + return memberRepository.findAllMembersRegardlessOfStatus(pageable); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/infrastructure/MemberStoreImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/infrastructure/MemberStoreImpl.java new file mode 100644 index 000000000..7d2d6403b --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/infrastructure/MemberStoreImpl.java @@ -0,0 +1,34 @@ +package kr.co.yigil.member.infrastructure; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberStore; +import kr.co.yigil.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberStoreImpl implements MemberStore { + private final MemberRepository memberRepository; + + + @Override + public void banMember(Long memberId) { + memberRepository.banMemberById(memberId); + } + + @Override + public void unbanMember(Long memberId) { + memberRepository.unbanMemberById(memberId); + } + + @Override + public void deleteMember(Long memberId) { + memberRepository.deleteById(memberId); + } + + @Override + public Member save(Member member) { + return memberRepository.save(member); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/controller/MemberApiController.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/controller/MemberApiController.java new file mode 100644 index 000000000..c60c334bc --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/controller/MemberApiController.java @@ -0,0 +1,49 @@ +package kr.co.yigil.member.interfaces.controller; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.application.MemberFacade; +import kr.co.yigil.member.interfaces.dto.mapper.MemberMapper; +import kr.co.yigil.member.interfaces.dto.request.MemberBanRequest; +import kr.co.yigil.member.interfaces.dto.request.MemberRequest; +import kr.co.yigil.member.interfaces.dto.response.MemberBanResponse; +import kr.co.yigil.member.interfaces.dto.response.MembersResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members") +public class MemberApiController { + private final MemberFacade memberFacade; + private final MemberMapper memberMapper; + + @GetMapping + public ResponseEntity getMembers(@ModelAttribute MemberRequest request) { + Pageable pageable = PageRequest.of(request.getPage() - 1, request.getDataCount()); + Page memberPage = memberFacade.getMemberPage(pageable); + MembersResponse response = memberMapper.toResponse(memberPage); + return ResponseEntity.ok(response); + } + + @PostMapping("/ban") + public ResponseEntity banMembers(@RequestBody MemberBanRequest request) { + memberFacade.banMembers(request); + return ResponseEntity.ok(new MemberBanResponse("회원 밴 완료")); + } + + @PostMapping("/unban") + public ResponseEntity unbanMembers(@RequestBody MemberBanRequest request) { + memberFacade.unbanMembers(request); + return ResponseEntity.ok(new MemberBanResponse("회원 밴 해제 완료")); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/mapper/MemberMapper.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/mapper/MemberMapper.java new file mode 100644 index 000000000..10f10b0df --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/mapper/MemberMapper.java @@ -0,0 +1,24 @@ +package kr.co.yigil.member.interfaces.dto.mapper; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.interfaces.dto.response.MemberInfoDto; +import kr.co.yigil.member.interfaces.dto.response.MembersResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.data.domain.Page; + +@Mapper(componentModel = "spring") +public interface MemberMapper { + + default MembersResponse toResponse(Page memberPage) { + Page memberInfoDtoPage = memberPage.map(this::toDto); + return new MembersResponse(memberInfoDtoPage); + } + + @Mapping(target = "memberId", source = "id") + @Mapping(target = "nickname", source = "nickname") + @Mapping(target = "profileImageUrl", source = "profileImageUrl") + @Mapping(target = "status", source = "status") + @Mapping(target = "joinedAt", source = "joinedAt", dateFormat = "yyyy-MM-dd HH:mm:ss") + MemberInfoDto toDto(Member member); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/request/MemberBanRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/request/MemberBanRequest.java new file mode 100644 index 000000000..67d2b457a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/request/MemberBanRequest.java @@ -0,0 +1,14 @@ +package kr.co.yigil.member.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MemberBanRequest { + private List ids; + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/request/MemberRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/request/MemberRequest.java new file mode 100644 index 000000000..cde8ec8c7 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/request/MemberRequest.java @@ -0,0 +1,13 @@ +package kr.co.yigil.member.interfaces.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MemberRequest { + private int page; + private int dataCount; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/request/MemberUnBanRequest.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/request/MemberUnBanRequest.java new file mode 100644 index 000000000..d99d5ec1c --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/request/MemberUnBanRequest.java @@ -0,0 +1,14 @@ +package kr.co.yigil.member.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MemberUnBanRequest { + private List ids; + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/dto/response/DeleteFavorResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MemberBanResponse.java similarity index 65% rename from backend/yigil-api/src/main/java/kr/co/yigil/favor/dto/response/DeleteFavorResponse.java rename to backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MemberBanResponse.java index 6c477d43c..4ee2062d7 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/favor/dto/response/DeleteFavorResponse.java +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MemberBanResponse.java @@ -1,4 +1,4 @@ -package kr.co.yigil.favor.dto.response; +package kr.co.yigil.member.interfaces.dto.response; import lombok.AllArgsConstructor; import lombok.Data; @@ -7,6 +7,7 @@ @Data @AllArgsConstructor @NoArgsConstructor -public class DeleteFavorResponse { +public class MemberBanResponse { + private String message; } diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MemberInfoDto.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MemberInfoDto.java new file mode 100644 index 000000000..ff7434dc5 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MemberInfoDto.java @@ -0,0 +1,19 @@ +package kr.co.yigil.member.interfaces.dto.response; + +import kr.co.yigil.member.MemberStatus; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Getter +@Builder +@ToString +public class MemberInfoDto { + private final Long memberId; + private final String nickname; + private final String profileImageUrl; + private final MemberStatus status; + private final String joinedAt; + + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MemberUnBanResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MemberUnBanResponse.java new file mode 100644 index 000000000..e82047ffd --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MemberUnBanResponse.java @@ -0,0 +1,13 @@ +package kr.co.yigil.member.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MemberUnBanResponse { + + private String message; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MembersResponse.java b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MembersResponse.java new file mode 100644 index 000000000..bdc12a06f --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/member/interfaces/dto/response/MembersResponse.java @@ -0,0 +1,14 @@ +package kr.co.yigil.member.interfaces.dto.response; + +import org.springframework.data.domain.Page; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MembersResponse { + private Page members; +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/application/NoticeFacade.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/application/NoticeFacade.java new file mode 100644 index 000000000..f3a976a7f --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/application/NoticeFacade.java @@ -0,0 +1,44 @@ +package kr.co.yigil.notice.application; + +import kr.co.yigil.notice.domain.NoticeCommand.NoticeCreateRequest; +import kr.co.yigil.notice.domain.NoticeCommand.NoticeUpdateRequest; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeDetail; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeListInfo; +import kr.co.yigil.notice.domain.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class NoticeFacade { + + private final NoticeService noticeService; + + public NoticeListInfo getNoticeList(PageRequest pageRequest) { + return noticeService.getNoticeList(pageRequest); + } + + public void createNotice(NoticeCreateRequest noticeCommand) { + noticeService.createNotice(noticeCommand); + } + + public NoticeDetail readNotice(Long noticeId) { + return noticeService.getNotice(noticeId); + } + + public void updateNotice(Long noticeId, NoticeUpdateRequest noticeCommand) { + noticeService.updateNotice(noticeId, noticeCommand); + } + + public void deleteNotice(Long noticeId) { + noticeService.deleteNotice(noticeId); + } + + private String getAuthor() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication.getName(); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeCommand.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeCommand.java new file mode 100644 index 000000000..9916be3e2 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeCommand.java @@ -0,0 +1,30 @@ +package kr.co.yigil.notice.domain; + +import kr.co.yigil.admin.domain.Admin; +import lombok.Builder; +import lombok.Getter; + +public class NoticeCommand { + + @Getter + @Builder + public static class NoticeCreateRequest{ + + private String title; + private String content; + + public Notice toEntity(Admin author) { + return new Notice(author, title, content); + } + + } + + @Getter + @Builder + public static class NoticeUpdateRequest{ + + private String title; + private String content; + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeInfo.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeInfo.java new file mode 100644 index 000000000..0ada477d2 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeInfo.java @@ -0,0 +1,99 @@ +package kr.co.yigil.notice.domain; + +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +public class NoticeInfo { + + public abstract static class BaseResponse { + + private final String message; + + protected BaseResponse(String message) { + this.message = message; + } + } + + @Getter + public static class NoticeListInfo { + + private final PageImpl noticeList; + + public NoticeListInfo(Page noticePage) { + this.noticeList = new PageImpl<>( + noticePage.getContent().stream().map(NoticeItem::new).toList(), + noticePage.getPageable(), + noticePage.getTotalElements()); + } + } + + @Getter + public static class NoticeItem { + + private final Long noticeId; + private final String title; + private final Long authorId; + private final String author; + private final String authorEmail; + private final String authorProfileImageUrl; + private final LocalDateTime createdAt; + + public NoticeItem(Notice notice) { + noticeId = notice.getId(); + title = notice.getTitle(); + author = notice.getAuthor().getNickname(); + authorId = notice.getAuthor().getId(); + authorEmail = notice.getAuthor().getEmail(); + authorProfileImageUrl = notice.getAuthorProfileImage(); + createdAt = notice.getCreatedAt(); + } + } + + @Getter + public static class NoticeCreateResponse extends BaseResponse { + + public NoticeCreateResponse(String message) { + super(message); + } + } + + @Getter + public static class NoticeUpdateResponse extends BaseResponse { + + public NoticeUpdateResponse(String message) { + super(message); + } + } + + @Getter + public static class NoticeDeleteResponse extends BaseResponse { + + public NoticeDeleteResponse(String message) { + super(message); + } + } + + @Getter + public static class NoticeDetail { + + private final Long noticeId; + private final String title; + private final String content; + private final Long authorId; + private final String authorNickname; + private final String profileImageUrl; + private final LocalDateTime createdAt; + + public NoticeDetail(Notice notice) { + this.noticeId = notice.getId(); + this.title = notice.getTitle(); + this.content = notice.getContent(); + this.authorNickname = notice.getAuthor().getNickname(); + this.authorId = notice.getAuthor().getId(); + this.profileImageUrl = notice.getAuthorProfileImage(); + this.createdAt = notice.getCreatedAt(); + } + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeReader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeReader.java new file mode 100644 index 000000000..79c1392af --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeReader.java @@ -0,0 +1,9 @@ +package kr.co.yigil.notice.domain; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +public interface NoticeReader { + Notice getNotice(Long noticeId); + Page getNoticeList(PageRequest pageRequest); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeService.java new file mode 100644 index 000000000..7fc9223ad --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeService.java @@ -0,0 +1,20 @@ +package kr.co.yigil.notice.domain; + +import kr.co.yigil.notice.domain.NoticeCommand.NoticeCreateRequest; +import kr.co.yigil.notice.domain.NoticeCommand.NoticeUpdateRequest; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeDetail; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeListInfo; +import org.springframework.data.domain.PageRequest; + +public interface NoticeService { + + void createNotice(NoticeCreateRequest noticeCommand); + + NoticeDetail getNotice(Long noticeId); + + NoticeListInfo getNoticeList(PageRequest pageRequest); + + void updateNotice(Long noticeId, NoticeUpdateRequest noticeCommand); + + void deleteNotice(Long noticeId); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeServiceImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeServiceImpl.java new file mode 100644 index 000000000..d81b0bae0 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeServiceImpl.java @@ -0,0 +1,59 @@ +package kr.co.yigil.notice.domain; + +import kr.co.yigil.admin.domain.admin.AdminReader; +import kr.co.yigil.notice.domain.NoticeCommand.NoticeCreateRequest; +import kr.co.yigil.notice.domain.NoticeCommand.NoticeUpdateRequest; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeListInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NoticeServiceImpl implements NoticeService{ + + private final NoticeReader noticeReader; + private final NoticeStore noticeStore; + private final AdminReader adminReader; + + @Override + @Transactional + public void createNotice(NoticeCreateRequest noticeCommand) { + Authentication authentication = SecurityContextHolder + .getContext() + .getAuthentication(); + var admin = adminReader.getAdminByEmail(authentication.getName()); + var notice = noticeCommand.toEntity(admin); + noticeStore.save(notice); + } + + @Override + @Transactional(readOnly = true) + public NoticeInfo.NoticeDetail getNotice(Long noticeId) { + var notice = noticeReader.getNotice(noticeId); + return new NoticeInfo.NoticeDetail(notice); + } + + @Override + @Transactional(readOnly = true) + public NoticeListInfo getNoticeList(PageRequest pageRequest) { + var noticePage = noticeReader.getNoticeList(pageRequest); + return new NoticeListInfo(noticePage); + } + + @Override + @Transactional + public void updateNotice(Long noticeId, NoticeUpdateRequest noticeCommand) { + var notice = noticeReader.getNotice(noticeId); + notice.updateNotice(noticeCommand.getTitle(), noticeCommand.getContent()); + } + + @Override + @Transactional + public void deleteNotice(Long noticeId) { + noticeStore.delete(noticeId); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeStore.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeStore.java new file mode 100644 index 000000000..f2f6bac48 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/domain/NoticeStore.java @@ -0,0 +1,9 @@ +package kr.co.yigil.notice.domain; + +public interface NoticeStore { + + Notice save(Notice notice); + + void delete(Long id); + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/infrastructure/NoticeReaderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/infrastructure/NoticeReaderImpl.java new file mode 100644 index 000000000..53514ccff --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/infrastructure/NoticeReaderImpl.java @@ -0,0 +1,28 @@ +package kr.co.yigil.notice.infrastructure; + +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.notice.domain.Notice; +import kr.co.yigil.notice.domain.NoticeReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NoticeReaderImpl implements NoticeReader { + private final NoticeRepository noticeRepository; + + @Override + public Notice getNotice(Long noticeId) { + return noticeRepository.findById(noticeId).orElseThrow( + () -> new BadRequestException(ExceptionCode.NOTICE_NOT_FOUND) + ); + } + + @Override + public Page getNoticeList(PageRequest pageRequest) { + return noticeRepository.findAll(pageRequest); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/infrastructure/NoticeStoreImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/infrastructure/NoticeStoreImpl.java new file mode 100644 index 000000000..5dbc19eea --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/infrastructure/NoticeStoreImpl.java @@ -0,0 +1,22 @@ +package kr.co.yigil.notice.infrastructure; + +import kr.co.yigil.notice.domain.Notice; +import kr.co.yigil.notice.domain.NoticeStore; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NoticeStoreImpl implements NoticeStore { + private final NoticeRepository noticeRepository; + + @Override + public Notice save(Notice notice) { + return noticeRepository.save(notice); + } + + @Override + public void delete(Long id) { + noticeRepository.deleteById(id); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/interfaces/controller/NoticeApiController.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/interfaces/controller/NoticeApiController.java new file mode 100644 index 000000000..1491a7949 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/interfaces/controller/NoticeApiController.java @@ -0,0 +1,87 @@ +package kr.co.yigil.notice.interfaces.controller; + +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import kr.co.yigil.notice.application.NoticeFacade; +import kr.co.yigil.notice.interfaces.dto.NoticeDto.NoticeCreateRequest; +import kr.co.yigil.notice.interfaces.dto.NoticeDto.NoticeCreateResponse; +import kr.co.yigil.notice.interfaces.dto.NoticeDto.NoticeDeleteResponse; +import kr.co.yigil.notice.interfaces.dto.NoticeDto.NoticeDetailResponse; +import kr.co.yigil.notice.interfaces.dto.NoticeDto.NoticeListResponse; +import kr.co.yigil.notice.interfaces.dto.NoticeDto.NoticeUpdateRequest; +import kr.co.yigil.notice.interfaces.dto.NoticeDto.NoticeUpdateResponse; +import kr.co.yigil.notice.interfaces.dto.mapper.NoticeMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +@RequiredArgsConstructor +@RequestMapping("/api/v1/notices") +public class NoticeApiController { + + private final NoticeFacade noticeFacade; + private final NoticeMapper noticeMapper; + + @GetMapping + public ResponseEntity geNoticeList( + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ){ + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber()-1, pageable.getPageSize(), Sort.by( + Direction.fromString(sortOrder.getValue()), sortBy.getValue())); + var notice = noticeFacade.getNoticeList(pageRequest); + var response = noticeMapper.toDto(notice); + return ResponseEntity.ok().body(response); + } + + @PostMapping + public ResponseEntity createNotice( + @RequestBody NoticeCreateRequest request + ){ + var noticeCommand = noticeMapper.toCommand(request); + noticeFacade.createNotice(noticeCommand); + return ResponseEntity.ok().body(new NoticeCreateResponse("공지사항 등록 완료")); + } + + @GetMapping("/{noticeId}") + public ResponseEntity readNotice( + @PathVariable("noticeId") Long noticeId + ){ + var noticeInfo = noticeFacade.readNotice(noticeId); + var response = noticeMapper.toDto(noticeInfo); + return ResponseEntity.ok().body(response); + } + + @PostMapping("/{noticeId}") + public ResponseEntity updateNotice( + @PathVariable("noticeId") Long noticeId, + @RequestBody NoticeUpdateRequest request + ){ + // 관리자 권한 필요 + var noticeCommand = noticeMapper.toCommand(request); + noticeFacade.updateNotice(noticeId, noticeCommand); + return ResponseEntity.ok().body(new NoticeUpdateResponse("공지사항 수정 완료")); + } + + @DeleteMapping("/{noticeId}") + public ResponseEntity deleteNotice( + @PathVariable("noticeId") Long noticeId + ){ + noticeFacade.deleteNotice(noticeId); + return ResponseEntity.ok().body(new NoticeDeleteResponse("공지사항 삭제 완료")); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/interfaces/dto/NoticeDto.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/interfaces/dto/NoticeDto.java new file mode 100644 index 000000000..2ab1cc9e0 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/interfaces/dto/NoticeDto.java @@ -0,0 +1,87 @@ +package kr.co.yigil.notice.interfaces.dto; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +public class NoticeDto { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class NoticeCreateRequest{ + private String title; + private String content; + } + + @Getter + @AllArgsConstructor + public static class NoticeUpdateRequest{ + private String title; + private String content; + } + + @Getter + @AllArgsConstructor + public abstract static class BaseMessageResponse { + private final String message; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class NoticeListResponse{ + Page noticeList; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class NoticeItem { + private Long noticeId; + private String title; + private Long authorId; + private String author; + private String authorEmail; + private String authorProfileImageUrl; + private LocalDateTime createdAt; + } + + @Getter + public static class NoticeCreateResponse extends BaseMessageResponse { + public NoticeCreateResponse(String message) { + super(message); + } + } + + @Getter + public static class NoticeUpdateResponse extends BaseMessageResponse { + public NoticeUpdateResponse(String message) { + super(message); + } + } + + @Getter + public static class NoticeDeleteResponse extends BaseMessageResponse { + public NoticeDeleteResponse(String message) { + super(message); + } + } + + @Getter + @Builder + public static class NoticeDetailResponse { + private Long noticeId; + private String title; + private String content; + private Long authorId; + private String authorNickname; + private String profileImageUrl; + private LocalDateTime createdAt; + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notice/interfaces/dto/mapper/NoticeMapper.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/interfaces/dto/mapper/NoticeMapper.java new file mode 100644 index 000000000..6cc953cd5 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notice/interfaces/dto/mapper/NoticeMapper.java @@ -0,0 +1,43 @@ +package kr.co.yigil.notice.interfaces.dto.mapper; + +import kr.co.yigil.notice.domain.NoticeCommand; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeDetail; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeItem; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeListInfo; +import kr.co.yigil.notice.interfaces.dto.NoticeDto; +import kr.co.yigil.notice.interfaces.dto.NoticeDto.NoticeCreateRequest; +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface NoticeMapper { + + @Mapping(target = "noticeList", source = "noticeList") + default NoticeDto.NoticeListResponse toDto(NoticeListInfo response){ + return new NoticeDto.NoticeListResponse(mapToPage(response.getNoticeList())); + } + + NoticeDto.NoticeItem of(NoticeItem noticeItem); + + NoticeDto.NoticeDetailResponse toDto(NoticeDetail response); + + NoticeCommand.NoticeCreateRequest toCommand(NoticeCreateRequest request); + + NoticeCommand.NoticeUpdateRequest toCommand(NoticeDto.NoticeUpdateRequest request); + + default Page mapToPage(Page page) { + return new PageImpl<>(page.getContent().stream() + .map(this::of) + .toList(), + page.getPageable(), + page.getTotalElements()); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationFactory.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationFactory.java new file mode 100644 index 000000000..501e80df8 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationFactory.java @@ -0,0 +1,14 @@ +package kr.co.yigil.notification.domain; + +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.member.Member; +import org.springframework.stereotype.Component; + +@Component +public class NotificationFactory { + public Notification createNotification(NotificationType notificationType, Admin admin, Member receiver) { + String message = notificationType.composeMessage(admin.getNickname(), receiver.getNickname()); + return new Notification(receiver, message); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationReader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationReader.java new file mode 100644 index 000000000..558f578b4 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationReader.java @@ -0,0 +1,13 @@ +package kr.co.yigil.notification.domain; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.codec.ServerSentEvent; +import reactor.core.publisher.Flux; + +public interface NotificationReader { + + Flux> getNotificationStream(Long memberId); + + Slice getNotificationSlice(Long memberId, Pageable pageable); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationSender.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationSender.java new file mode 100644 index 000000000..c531a2d35 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationSender.java @@ -0,0 +1,7 @@ +package kr.co.yigil.notification.domain; + +public interface NotificationSender { + + void sendNotification(NotificationType notificationType, Long senderId, + Long receiverId); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationService.java new file mode 100644 index 000000000..41bfcc79f --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationService.java @@ -0,0 +1,10 @@ +package kr.co.yigil.notification.domain; + +public interface NotificationService { + +// Flux> getNotificationStream(Long memberId); + + void sendNotification(NotificationType notificationType, Long senderId, Long receiverId); + +// Slice getNotificationSlice(Long memberId, PageRequest pageRequest); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationServiceImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationServiceImpl.java new file mode 100644 index 000000000..8fd7dd212 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationServiceImpl.java @@ -0,0 +1,30 @@ +package kr.co.yigil.notification.domain; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NotificationServiceImpl implements NotificationService{ + + private final NotificationSender notificationSender; +// @Transactional(readOnly = true) +// @Override +// public Flux> getNotificationStream(Long memberId) { +// return notificationReader.getNotificationStream(memberId); +// } + + @Transactional + @Override + public void sendNotification(NotificationType notificationType, Long senderId, Long receiverId) { + notificationSender.sendNotification(notificationType, senderId, receiverId); + } + +// @Transactional(readOnly = true) +// @Override +// public Slice getNotificationSlice(Long memberId, PageRequest pageRequest) { +// return notificationReader.getNotificationSlice(memberId, pageRequest); +// } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationStore.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationStore.java new file mode 100644 index 000000000..177d65f6c --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationStore.java @@ -0,0 +1,6 @@ +package kr.co.yigil.notification.domain; + +public interface NotificationStore { + + void store(Notification notification); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationType.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationType.java new file mode 100644 index 000000000..16a58d743 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/domain/NotificationType.java @@ -0,0 +1,19 @@ +package kr.co.yigil.notification.domain; + +import java.util.function.BinaryOperator; + +public enum NotificationType { + SPOT_DELETED((sender, receiever) -> sender + "님이 게시글 리뷰를 삭제하셨습니다"), + COURSE_DELETED((sender, receiever) -> sender + "님이 일정을 삭제하셨습니다"), + COMMENT_DELETE((sender, receiever) -> sender + "님이 댓글을 삭제하셨습니다"); + + private final BinaryOperator messageComposer; + + NotificationType(BinaryOperator messageComposer) { + this.messageComposer = messageComposer; + } + + public String composeMessage(String sender, String receiver) { + return messageComposer.apply(sender, receiver); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/application/NotificationService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/infrastructure/NotificationReaderImpl.java similarity index 59% rename from backend/yigil-api/src/main/java/kr/co/yigil/notification/application/NotificationService.java rename to backend/yigil-admin/src/main/java/kr/co/yigil/notification/infrastructure/NotificationReaderImpl.java index b1305c1e6..855ff4c84 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/notification/application/NotificationService.java +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/infrastructure/NotificationReaderImpl.java @@ -1,30 +1,22 @@ -package kr.co.yigil.notification.application; +package kr.co.yigil.notification.infrastructure; import kr.co.yigil.notification.domain.Notification; -import kr.co.yigil.notification.domain.repository.NotificationRepository; +import kr.co.yigil.notification.domain.NotificationReader; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.http.codec.ServerSentEvent; -import org.springframework.stereotype.Service; -import reactor.core.publisher.EmitterProcessor; +import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; -@Service +@Component @RequiredArgsConstructor -public class NotificationService { - +public class NotificationReaderImpl implements NotificationReader { private final Sinks.Many sink = Sinks.many().multicast().onBackpressureBuffer(); private final NotificationRepository notificationRepository; - public void sendNotification(Notification notification) { - notificationRepository.save(notification); - sendRealTimeNotification(notification); - } - - private void sendRealTimeNotification(Notification notification) { - sink.tryEmitNext(notification); - } - + @Override public Flux> getNotificationStream(Long memberId) { return sink.asFlux() .filter(notification -> notification.getMember().getId().equals(memberId)) @@ -32,4 +24,9 @@ public Flux> getNotificationStream(Long memberId) .data(notification) .build()); } + + @Override + public Slice getNotificationSlice(Long memberId, Pageable pageable) { + return notificationRepository.findAllByMemberId(memberId, pageable); + } } diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notification/infrastructure/NotificationSenderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/infrastructure/NotificationSenderImpl.java new file mode 100644 index 000000000..72ed315a1 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/infrastructure/NotificationSenderImpl.java @@ -0,0 +1,35 @@ +package kr.co.yigil.notification.infrastructure; + +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.domain.admin.AdminReader; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.domain.NotificationFactory; +import kr.co.yigil.notification.domain.NotificationSender; +import kr.co.yigil.notification.domain.NotificationStore; +import kr.co.yigil.notification.domain.NotificationType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Sinks; +@Component +@RequiredArgsConstructor +public class NotificationSenderImpl implements NotificationSender { + private final MemberReader memberReader; + private final AdminReader adminReader; + private final Sinks.Many sink = Sinks.many().multicast().onBackpressureBuffer(); + private final NotificationStore notificationStore; + private final NotificationFactory notificationFactory; + @Override + public void sendNotification(NotificationType notificationType, Long adminId, + Long receiverId) { + Admin sender = adminReader.getAdmin(adminId); + Member receiver = memberReader.getMember(receiverId); + Notification notification = notificationFactory.createNotification(notificationType, sender, receiver); + notificationStore.store(notification); + sendRealTimeNotification(notification); + } + private void sendRealTimeNotification(Notification notification) { + sink.tryEmitNext(notification); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/notification/infrastructure/NotificationStoreImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/infrastructure/NotificationStoreImpl.java new file mode 100644 index 000000000..807ada31c --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/notification/infrastructure/NotificationStoreImpl.java @@ -0,0 +1,18 @@ +package kr.co.yigil.notification.infrastructure; + +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.domain.NotificationStore; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NotificationStoreImpl implements NotificationStore { + private final NotificationRepository notificationRepository; + + @Override + public void store(Notification notification) { + notificationRepository.save(notification); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/application/CourseFacade.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/application/CourseFacade.java new file mode 100644 index 000000000..5d2d7eb1c --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/application/CourseFacade.java @@ -0,0 +1,36 @@ +package kr.co.yigil.travel.course.application; + +import kr.co.yigil.admin.domain.admin.AdminService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +import kr.co.yigil.travel.course.domain.CourseInfoDto; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CourseDetailInfo; +import kr.co.yigil.travel.course.domain.CourseService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CourseFacade { + + private final CourseService courseService; + private final AdminService adminService; + private final NotificationService notificationService; + + public CourseInfoDto.CoursesPageInfo getCourses(PageRequest pageRequest) { + return courseService.getCourses(pageRequest); + } + + public CourseDetailInfo getCourse(Long courseId) { + return courseService.getCourse(courseId); + } + + public void deleteCourse(Long courseId) { + Long memberId = courseService.deleteCourse(courseId); + Long adminId = adminService.getAdminId(); + notificationService.sendNotification(NotificationType.COURSE_DELETED, adminId, memberId); + } + + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseInfoDto.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseInfoDto.java new file mode 100644 index 000000000..7f13d3b47 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseInfoDto.java @@ -0,0 +1,83 @@ +package kr.co.yigil.travel.course.domain; + +import java.time.LocalDateTime; +import java.util.List; +import kr.co.yigil.travel.domain.Course; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +public class CourseInfoDto { + + @Data + public static class CoursesPageInfo { + + private final Page courses; + + public CoursesPageInfo(List courses, Pageable pageable, + long totalElements) { + this.courses = new PageImpl<>(courses, pageable, totalElements); + } + } + + @Data + @AllArgsConstructor + public static class CourseListUnit { + + private final Long courseId; + private final String title; + private final LocalDateTime createdAt; + private final int favorCount; + private final int commentCount; + + public CourseListUnit(Course course, CourseAdditionalInfo courseAdditionalInfo) { + this.courseId = course.getId(); + this.title = course.getTitle(); + this.createdAt = course.getCreatedAt(); + this.favorCount = courseAdditionalInfo.getFavorCount(); + this.commentCount = courseAdditionalInfo.getCommentCount(); + + } + } + + @Data + @AllArgsConstructor + public static class CourseDetailInfo { + + private final Long courseId; + private final String title; + private final String content; + private final String mapStaticImageUrl; + private final LocalDateTime createdAt; + private final double rate; + private final Long writerId; + private final String writerName; + private final int favorCount; + private final int commentCount; + + public CourseDetailInfo(final Course course, + final CourseAdditionalInfo courseAdditionalInfo) { + this.courseId = course.getId(); + this.title = course.getTitle(); + this.content = course.getDescription(); + this.mapStaticImageUrl = course.getMapStaticImageFileUrl(); + this.createdAt = course.getCreatedAt(); + this.rate = course.getRate(); + this.writerId = course.getMember().getId(); + this.writerName = course.getMember().getNickname(); + this.favorCount = courseAdditionalInfo.getFavorCount(); + this.commentCount = courseAdditionalInfo.getCommentCount(); + } + } + + @Data + @AllArgsConstructor + public static class CourseAdditionalInfo { + + private int favorCount; + private int commentCount; + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseReader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseReader.java new file mode 100644 index 000000000..cd2e0e5d0 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseReader.java @@ -0,0 +1,13 @@ +package kr.co.yigil.travel.course.domain; + + +import kr.co.yigil.travel.domain.Course; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +public interface CourseReader { + + Page getCourses(PageRequest pageRequest); + + Course getCourse(Long courseId); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseService.java new file mode 100644 index 000000000..2af0c8525 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseService.java @@ -0,0 +1,14 @@ +package kr.co.yigil.travel.course.domain; + +import kr.co.yigil.travel.course.domain.CourseInfoDto.CourseDetailInfo; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CoursesPageInfo; +import org.springframework.data.domain.PageRequest; + +public interface CourseService { + + CoursesPageInfo getCourses(PageRequest pageRequest); + + CourseDetailInfo getCourse(Long courseId); + + Long deleteCourse(Long courseId); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseServiceImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseServiceImpl.java new file mode 100644 index 000000000..c45dae74b --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseServiceImpl.java @@ -0,0 +1,64 @@ +package kr.co.yigil.travel.course.domain; + +import java.util.List; +import kr.co.yigil.comment.domain.CommentReader; +import kr.co.yigil.favor.domain.FavorReader; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CourseAdditionalInfo; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CourseDetailInfo; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CourseListUnit; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CoursesPageInfo; +import kr.co.yigil.travel.domain.Course; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CourseServiceImpl implements CourseService { + + private final CourseReader courseReader; + private final CourseStore courseStore; + private final FavorReader favorReader; + private final CommentReader commentReader; + + @Override + @Transactional(readOnly = true) + public CoursesPageInfo getCourses(PageRequest pageRequest) { + Page courses = courseReader.getCourses(pageRequest); + List courseList = courses.getContent(); + var courseListUnits = courseList.stream() + .map(this::getCourseListUnit) + .toList(); + + return new CoursesPageInfo( + courseListUnits, courses.getPageable(), courses.getTotalElements() + ); + } + + @Override + @Transactional(readOnly = true) + public CourseDetailInfo getCourse(Long courseId) { + Course course = courseReader.getCourse(courseId); + return new CourseDetailInfo(course, getAdditionalInfo(courseId)); + } + + @Override + @Transactional + public Long deleteCourse(Long courseId) { + Course course = courseReader.getCourse(courseId); + courseStore.deleteCourse(course); + return course.getMember().getId(); + } + + private CourseAdditionalInfo getAdditionalInfo(Long courseId) { + int favorCount = favorReader.getFavorCount(courseId); + int commentCount = commentReader.getCommentCount(courseId); + return new CourseAdditionalInfo(favorCount, commentCount); + } + + private CourseListUnit getCourseListUnit(Course course) { + return new CourseListUnit(course, getAdditionalInfo(course.getId())); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseStore.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseStore.java new file mode 100644 index 000000000..4fb14b32e --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/domain/CourseStore.java @@ -0,0 +1,8 @@ +package kr.co.yigil.travel.course.domain; + +import kr.co.yigil.travel.domain.Course; + +public interface CourseStore { + + void deleteCourse(Course course); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/infrastructure/CourseReaderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/infrastructure/CourseReaderImpl.java new file mode 100644 index 000000000..fe7fe41b4 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/infrastructure/CourseReaderImpl.java @@ -0,0 +1,30 @@ +package kr.co.yigil.travel.course.infrastructure; + +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.travel.course.domain.CourseReader; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.infrastructure.CourseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class CourseReaderImpl implements CourseReader { + private final CourseRepository courseRepository; + + @Override + public Page getCourses(PageRequest pageRequest) { + return courseRepository.findAll(pageRequest); + } + + @Override + public Course getCourse(Long courseId) { + return courseRepository.findById(courseId).orElseThrow( + () -> new BadRequestException(ExceptionCode.NOT_FOUND_COURSE_ID) + ); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/infrastructure/CourseStoreImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/infrastructure/CourseStoreImpl.java new file mode 100644 index 000000000..3364985ee --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/infrastructure/CourseStoreImpl.java @@ -0,0 +1,19 @@ +package kr.co.yigil.travel.course.infrastructure; + +import kr.co.yigil.travel.course.domain.CourseStore; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.infrastructure.CourseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class CourseStoreImpl implements CourseStore { + private final CourseRepository courseRepository; + + @Override + public void deleteCourse(Course course) { + courseRepository.delete(course); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/interfaces/controller/CourseApiController.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/interfaces/controller/CourseApiController.java new file mode 100644 index 000000000..1bd72ba3e --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/interfaces/controller/CourseApiController.java @@ -0,0 +1,67 @@ +package kr.co.yigil.travel.course.interfaces.controller; + + +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import kr.co.yigil.travel.course.application.CourseFacade; +import kr.co.yigil.travel.course.domain.CourseInfoDto; +import kr.co.yigil.travel.course.interfaces.dto.CourseDto.CourseDeleteResponse; +import kr.co.yigil.travel.course.interfaces.dto.CourseDto.CourseDetailResponse; +import kr.co.yigil.travel.course.interfaces.dto.CourseDto.CoursesResponse; +import kr.co.yigil.travel.course.interfaces.dto.mapper.CourseDtoMapper; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/courses") +public class CourseApiController { + + private final CourseFacade courseFacade; + private final CourseDtoMapper courseDtoMapper; + + @GetMapping + public ResponseEntity getCourses( + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of( + pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue()) + ); + CourseInfoDto.CoursesPageInfo courses = courseFacade.getCourses(pageRequest); + CoursesResponse response = courseDtoMapper.toPageDtp(courses); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/{courseId}") + public ResponseEntity getCourse( + @PathVariable Long courseId) { + CourseInfoDto.CourseDetailInfo course = courseFacade.getCourse(courseId); + CourseDetailResponse response = courseDtoMapper.toDetailDto(course); + return ResponseEntity.ok().body(response); + } + + @DeleteMapping("/{courseId}") + public ResponseEntity deleteCourse( + @PathVariable Long courseId) { + courseFacade.deleteCourse(courseId); + return ResponseEntity.ok().body(new CourseDeleteResponse("삭제 성공")); + } + + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/interfaces/dto/CourseDto.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/interfaces/dto/CourseDto.java new file mode 100644 index 000000000..4ba1d7c63 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/interfaces/dto/CourseDto.java @@ -0,0 +1,55 @@ +package kr.co.yigil.travel.course.interfaces.dto; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +public class CourseDto { + + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CoursesResponse { + private Page courses; + } + + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CourseListUnit { + private Long courseId; + private String title; + private LocalDateTime createdAt; + private int favorCount; + private int commentCount; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CourseDetailResponse { + private Long courseId; + private String title; + private String content; + + private String mapStaticImageUrl; + private LocalDateTime createdAt; + private double rate; + private int favorCount; + private int commentCount; + + private Long writerId; + private String writerName; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CourseDeleteResponse { + private String message; + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/interfaces/dto/mapper/CourseDtoMapper.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/interfaces/dto/mapper/CourseDtoMapper.java new file mode 100644 index 000000000..389ed1ebf --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/course/interfaces/dto/mapper/CourseDtoMapper.java @@ -0,0 +1,39 @@ +package kr.co.yigil.travel.course.interfaces.dto.mapper; + +import kr.co.yigil.travel.course.domain.CourseInfoDto; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CourseDetailInfo; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CourseListUnit; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CoursesPageInfo; +import kr.co.yigil.travel.course.interfaces.dto.CourseDto; +import kr.co.yigil.travel.course.interfaces.dto.CourseDto.CourseDetailResponse; +import kr.co.yigil.travel.course.interfaces.dto.CourseDto.CoursesResponse; +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface CourseDtoMapper { + + default CoursesResponse toPageDtp(CoursesPageInfo courses){ + return new CoursesResponse(mapToPage(courses.getCourses())); + } + + CourseDto.CourseListUnit of(CourseInfoDto.CourseListUnit course); + + CourseDetailResponse toDetailDto(CourseDetailInfo course); + + default Page mapToPage(Page page) { + return new PageImpl<>( + page.getContent().stream().map(this::of) + .toList(), + page.getPageable(), + page.getTotalElements() + ); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/application/SpotFacade.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/application/SpotFacade.java new file mode 100644 index 000000000..b24e775d1 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/application/SpotFacade.java @@ -0,0 +1,36 @@ +package kr.co.yigil.travel.spot.application; + + +import kr.co.yigil.admin.domain.admin.AdminService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotDetailInfo; +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotPageInfo; +import kr.co.yigil.travel.spot.domain.SpotService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SpotFacade { + + private final SpotService spotService; + private final NotificationService notificationService; + private final AdminService adminService; + + public SpotPageInfo getSpots(Pageable pageable) { + return spotService.getSpots(pageable); + } + + public SpotDetailInfo getSpot(Long spotId) { + return spotService.getSpot(spotId); + } + + public void deleteSpot(Long spotId) { + var memberId = spotService.deleteSpot(spotId); + var adminId = adminService.getAdminId(); + notificationService.sendNotification(NotificationType.SPOT_DELETED, adminId, memberId); + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotInfoDto.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotInfoDto.java new file mode 100644 index 000000000..36a83f658 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotInfoDto.java @@ -0,0 +1,82 @@ +package kr.co.yigil.travel.spot.domain; + +import java.time.LocalDateTime; +import java.util.List; +import kr.co.yigil.travel.domain.Spot; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +public class SpotInfoDto { + + @Data + public static class SpotPageInfo { + + private final Page spots; + + public SpotPageInfo(List spots, Pageable pageable, long totalElements) { + this.spots = new PageImpl<>(spots, pageable, totalElements); + } + } + + @Data + @AllArgsConstructor + public static class SpotListUnit { + + private final Long spotId; + private final String title; + private final LocalDateTime createdAt; + private final int favorCount; + private final int commentCount; + + public SpotListUnit(Spot spot, SpotAdditionalInfo additionalInfo) { + this.spotId = spot.getId(); + this.title = spot.getTitle(); + this.favorCount = additionalInfo.getFavorCount(); + this.commentCount = additionalInfo.getCommentCount(); + this.createdAt = spot.getCreatedAt(); + } + } + + @Data + @AllArgsConstructor + public static class SpotDetailInfo { + + private final Long spotId; + private final String title; + private final String content; + private final String placeName; + private final String mapStaticImageUrl; + private final double rate; + private final int favorCount; + private final int commentCount; + private final List imageUrls; + private final Long writerId; + private final String writerName; + private final LocalDateTime createdAt; + + public SpotDetailInfo(Spot spot, SpotAdditionalInfo additionalInfo) { + this.spotId = spot.getId(); + this.title = spot.getTitle(); + this.content = spot.getDescription(); + this.placeName = spot.getPlace().getName(); + this.mapStaticImageUrl = spot.getPlace().getMapStaticImageFileUrl(); + this.rate = spot.getRate(); + this.imageUrls = spot.getAttachFiles().getUrls(); + this.writerId = spot.getMember().getId(); + this.writerName = spot.getMember().getNickname(); + this.createdAt = spot.getCreatedAt(); + this.favorCount = additionalInfo.getFavorCount(); + this.commentCount = additionalInfo.getCommentCount(); + } + } + + @Data + @AllArgsConstructor + public static class SpotAdditionalInfo{ + private int favorCount; + private int commentCount; + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotReader.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotReader.java new file mode 100644 index 000000000..e47b66cb5 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotReader.java @@ -0,0 +1,13 @@ +package kr.co.yigil.travel.spot.domain; + +import kr.co.yigil.travel.domain.Spot; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface SpotReader { + + Page getSpots(Pageable pageable); + + Spot getSpot(Long spotId); + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotService.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotService.java new file mode 100644 index 000000000..9594d9f3a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotService.java @@ -0,0 +1,15 @@ +package kr.co.yigil.travel.spot.domain; + +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotDetailInfo; +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotPageInfo; +import org.springframework.data.domain.Pageable; + +public interface SpotService { + + + SpotDetailInfo getSpot(Long spotId); + + SpotPageInfo getSpots(Pageable pageable); + + Long deleteSpot(Long spotId); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotServiceImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotServiceImpl.java new file mode 100644 index 000000000..54a6371ce --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotServiceImpl.java @@ -0,0 +1,65 @@ +package kr.co.yigil.travel.spot.domain; + +import kr.co.yigil.comment.domain.CommentReader; +import kr.co.yigil.favor.domain.FavorReader; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotDetailInfo; +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotListUnit; +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotPageInfo; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SpotServiceImpl implements SpotService { + + private final SpotReader spotReader; + private final SpotStore spotStore; + private final FavorReader favorReader; + private final CommentReader commentReader; + + + @Override + @Transactional(readOnly = true) + public SpotPageInfo getSpots(Pageable pageable) { + Page pageSpots = spotReader.getSpots(pageable); + + var spotList = pageSpots.getContent().stream().map(this::getSpotListUnit).toList(); + + return new SpotPageInfo(spotList, pageSpots.getPageable(), pageSpots.getTotalElements()); + } + + + @Override + @Transactional(readOnly = true) + public SpotDetailInfo getSpot(Long spotId) { + Spot spot = spotReader.getSpot(spotId); + var spotAdditionalInfo = getAdditionalInfo(spotId); + return new SpotDetailInfo(spot, spotAdditionalInfo); + + } + + @Override + @Transactional + public Long deleteSpot(Long spotId) { + Spot spot = spotReader.getSpot(spotId); + spotStore.deleteSpot(spot); + return spot.getMember().getId(); + } + + private SpotInfoDto.SpotAdditionalInfo getAdditionalInfo(Long id) { + int favorCount = favorReader.getFavorCount(id); + int commentCount = commentReader.getCommentCount(id); + return new SpotInfoDto.SpotAdditionalInfo(favorCount, commentCount); + } + + @NotNull + private SpotListUnit getSpotListUnit(Spot spot) { + var spotAdditionalInfo = getAdditionalInfo(spot.getId()); + return new SpotListUnit(spot, spotAdditionalInfo); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotStore.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotStore.java new file mode 100644 index 000000000..c6ccf9966 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/domain/SpotStore.java @@ -0,0 +1,8 @@ +package kr.co.yigil.travel.spot.domain; + +import kr.co.yigil.travel.domain.Spot; + +public interface SpotStore { + + void deleteSpot(Spot spot); +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/infrastructure/SpotReaderImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/infrastructure/SpotReaderImpl.java new file mode 100644 index 000000000..90fbb61ee --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/infrastructure/SpotReaderImpl.java @@ -0,0 +1,32 @@ +package kr.co.yigil.travel.spot.infrastructure; + + +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.infrastructure.SpotRepository; +import kr.co.yigil.travel.spot.domain.SpotReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpotReaderImpl implements SpotReader { + + private final SpotRepository spotRepository; + + + @Override + public Page getSpots(Pageable pageable) { + return spotRepository.findAll(pageable); + } + + @Override + public Spot getSpot(Long spotId) { + return spotRepository.findById(spotId).orElseThrow( + () -> new BadRequestException(ExceptionCode.NOT_FOUND_SPOT_ID) + ); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/infrastructure/SpotStoreImpl.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/infrastructure/SpotStoreImpl.java new file mode 100644 index 000000000..a2f68741c --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/infrastructure/SpotStoreImpl.java @@ -0,0 +1,20 @@ +package kr.co.yigil.travel.spot.infrastructure; + + +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.infrastructure.SpotRepository; +import kr.co.yigil.travel.spot.domain.SpotStore; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpotStoreImpl implements SpotStore { + + private final SpotRepository spotRepository; + + @Override + public void deleteSpot(Spot spot) { + spotRepository.delete(spot); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/interfaces/controller/SpotApiController.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/interfaces/controller/SpotApiController.java new file mode 100644 index 000000000..8e22cc58a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/interfaces/controller/SpotApiController.java @@ -0,0 +1,60 @@ +package kr.co.yigil.travel.spot.interfaces.controller; + + +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import kr.co.yigil.travel.spot.application.SpotFacade; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto.SpotDeleteResponse; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto.SpotDetailResponse; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto.SpotsResponse; +import kr.co.yigil.travel.spot.interfaces.dto.mapper.SpotDtoMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/spots") +public class SpotApiController { + + private final SpotFacade spotFacade; + private final SpotDtoMapper spotDtoMapper; + + @GetMapping + public ResponseEntity getSpots( + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + var spots = spotFacade.getSpots(pageRequest); + var response = spotDtoMapper.of(spots); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/{spotId}") + public ResponseEntity getSpot(@PathVariable Long spotId) { + var spot = spotFacade.getSpot(spotId); + var response = spotDtoMapper.of(spot); + return ResponseEntity.ok().body(response); + } + + @DeleteMapping("/{spotId}") + public ResponseEntity deleteSpot( + @PathVariable Long spotId) { + spotFacade.deleteSpot(spotId); + return ResponseEntity.ok().body(new SpotDeleteResponse("삭제 성공")); + } +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/interfaces/dto/SpotDto.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/interfaces/dto/SpotDto.java new file mode 100644 index 000000000..ac639cba7 --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/interfaces/dto/SpotDto.java @@ -0,0 +1,64 @@ +package kr.co.yigil.travel.spot.interfaces.dto; + + +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +public class SpotDto { + + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SpotsResponse { + private Page spots; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + + public static class SpotListInfo { + + private Long spotId; + private String title; + private LocalDateTime createdAt; + private int favorCount; + private int commentCount; + } + + + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SpotDetailResponse { + private Long spotId; + private String title; + private String content; + private String placeName; + + private String mapStaticImageUrl; + private LocalDateTime createdAt; + private double rate; + private int favorCount; + private int commentCount; + private List imageUrls; + + private Long writerId; + private String writerName; + } + + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SpotDeleteResponse { + private String message; + } + +} diff --git a/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/interfaces/dto/mapper/SpotDtoMapper.java b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/interfaces/dto/mapper/SpotDtoMapper.java new file mode 100644 index 000000000..bb851620a --- /dev/null +++ b/backend/yigil-admin/src/main/java/kr/co/yigil/travel/spot/interfaces/dto/mapper/SpotDtoMapper.java @@ -0,0 +1,38 @@ +package kr.co.yigil.travel.spot.interfaces.dto.mapper; + +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotDetailInfo; +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotListUnit; +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotPageInfo; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto.SpotDetailResponse; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto.SpotListInfo; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto.SpotsResponse; +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface SpotDtoMapper { + + default SpotsResponse of(SpotPageInfo spots){ + return new SpotsResponse(mapToPage(spots.getSpots())); + } + + SpotDto.SpotListInfo of(SpotListUnit spot); + + SpotDetailResponse of(SpotDetailInfo spot); + + default Page mapToPage(Page page) { + return new PageImpl<>(page.getContent().stream() + .map(this::of) + .toList(), + page.getPageable(), + page.getTotalElements()); + } +} diff --git a/backend/yigil-admin/src/main/resources/application.yml b/backend/yigil-admin/src/main/resources/application.yml new file mode 100644 index 000000000..30b7a5adc --- /dev/null +++ b/backend/yigil-admin/src/main/resources/application.yml @@ -0,0 +1,83 @@ +spring: + servlet: + multipart: + max-file-size: 50MB + max-request-size: 250MB + jpa: + database: postgresql + database-platform: org.hibernate.spatial.dialect.postgis.PostgisPG95Dialect + hibernate: + ddl-auto: validate + defer-datasource-initialization: true + datasource: + master: + hikari: + driver-class-name: org.postgresql.Driver + jdbc-url: @MASTER_DB_URL@ + read-only: false + username: @MASTER_DB_USERNAME@ + password: @MASTER_DB_PASSWORD@ + slave: + hikari: + driver-class-name: org.postgresql.Driver + jdbc-url: @SLAVE_DB_URL@ + read-only: true + username: @SLAVE_DB_USERNAME@ + password: @SLAVE_DB_PASSWORD@ + data: + redis: + host: @REDIS_HOST@ + port: @REDIS_PORT@ + jackson: + property-naming-strategy: SNAKE_CASE + mail: + host: @MAIL_HOST@ + port: @MAIL_PORT@ + username: @MAIL_USERNAME@ + password: @MAIL_PASSWORD@ + + properties: + mail: + smtp: + auth: true + timeout: 5000 + starttls: + enable: true + +cloud: + aws: + s3: + bucket: @S3_BUCKET@ + credentials: + access-key: @AWS_ACCESS_KEY@ + secret-key: @AWS_SECRET_KEY@ + region: + static: ap-northeast-2 + auto: false + stack: + auto: false + +server: + port: @YIGIL_ADMIN_PORT@ + servlet: + session: + cookie: + http-only: true + secure: false + +jasypt: + encryptor: + bean: jasyptStringEncryptor + +logging: + level: + root: DEBUG + slack: + webhook-uri: @SLACK_WEBHOOK_URI@ +jwt: + secret: @JWT_SECRET@ + +decorator: + datasource: + p6spy: + enable-logging: true \ No newline at end of file diff --git a/backend/yigil-admin/src/main/resources/logback-spring.xml b/backend/yigil-admin/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..99fb68edd --- /dev/null +++ b/backend/yigil-admin/src/main/resources/logback-spring.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + INFO + + + ${CONSOLE_LOG_PATTERN} + + + + + ${SLACK_WEBHOOK_URL} + + + ${PID:-} --- [%15.15thread] %-40.40logger{36} %msg%n%n + [CURRENT_MODULE] : ADMIN-API%n + [REQUEST_ID] : %X{REQUEST_ID:-NO REQUEST ID}%n + [REQUEST_METHOD] : %X{REQUEST_METHOD:-NO REQUEST METHOD}%n + [REQUEST_URI] : %X{REQUEST_URI:-NO REQUEST URI}%n + [REQUEST_TIME] : %d{yyyy-MM-dd HH:mm:ss.SSS}%n + [REQUEST_IP] : %X{REQUEST_IP:-NO REQUEST IP}%n + + utf8 + + true + + + + + + ERROR + ACCEPT + DENY + + + + + + + + \ No newline at end of file diff --git a/backend/yigil-admin/src/main/resources/spy.properties b/backend/yigil-admin/src/main/resources/spy.properties new file mode 100644 index 000000000..891d0a343 --- /dev/null +++ b/backend/yigil-admin/src/main/resources/spy.properties @@ -0,0 +1,3 @@ +appender=com.p6spy.engine.spy.appender.Slf4JLogger +logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat +customLogMessageFormat=| %(executionTime) ms | %(sql) \ No newline at end of file diff --git a/backend/yigil-admin/src/main/resources/templates/accept.html b/backend/yigil-admin/src/main/resources/templates/accept.html new file mode 100644 index 000000000..94f148c37 --- /dev/null +++ b/backend/yigil-admin/src/main/resources/templates/accept.html @@ -0,0 +1,18 @@ + + + +
+

안녕하세요.

+

지도 기반의 일정 및 장소 공유 플랫폼 서비스 이길로그 입니다.

+
+

임시 비밀번호를 발급드립니다. 아래 발급된 비밀번호로 로그인해주세요.

+
+ +
+

임시 비밀번호 입니다.

+
+
+
+
+ + \ No newline at end of file diff --git a/backend/yigil-admin/src/main/resources/templates/reject.html b/backend/yigil-admin/src/main/resources/templates/reject.html new file mode 100644 index 000000000..9968610f9 --- /dev/null +++ b/backend/yigil-admin/src/main/resources/templates/reject.html @@ -0,0 +1,18 @@ + + + +
+

안녕하세요.

+

지도 기반의 일정 및 장소 공유 플랫폼 서비스 이길로그 입니다.

+
+

관리자 검토 결과, 가입이 거절되었음을 알려 드립니다.

+
+ +
+

거절 사유

+
관리자 반려
+
+
+
+ + \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/AdminApplicationTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/AdminApplicationTest.java new file mode 100644 index 000000000..794a34070 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/AdminApplicationTest.java @@ -0,0 +1,13 @@ +package kr.co.yigil; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = AdminApplicationTest.class) +public class AdminApplicationTest { + + @Test + void contextLoads() { + + } +} diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/application/AdminFacadeTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/application/AdminFacadeTest.java new file mode 100644 index 000000000..c9a3bbebb --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/application/AdminFacadeTest.java @@ -0,0 +1,169 @@ +package kr.co.yigil.admin.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.admin.AdminCommand; +import kr.co.yigil.admin.domain.admin.AdminCommand.LoginRequest; +import kr.co.yigil.admin.domain.admin.AdminInfo.AdminDetailInfoResponse; +import kr.co.yigil.admin.domain.admin.AdminInfo.AdminInfoResponse; +import kr.co.yigil.admin.domain.admin.AdminService; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpCommand.AdminSignUpRequest; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpService; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignUpListRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpAcceptRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpRejectRequest; +import kr.co.yigil.auth.dto.JwtToken; +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.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class AdminFacadeTest { + + @Mock + private AdminService adminService; + + @Mock + private AdminSignUpService adminSignUpService; + + @InjectMocks + private AdminFacade adminFacade; + + @DisplayName("sendSignUpRequest 메서드가 AdminSignUpService를 잘 호출하는지") + @Test + void sendSignUpRequest_ShouldCallService() { + AdminSignUpRequest command = mock(AdminSignUpRequest.class); + + doNothing().when(adminSignUpService).sendSignUpRequest(command); + + adminFacade.sendSignUpRequest(command); + + verify(adminSignUpService).sendSignUpRequest(command); + } + + @DisplayName("getSignUpRequestList 메서드가 Page를 잘 반환하는지") + @Test + void getSignUpRequestList_ShouldReturnPage() { + AdminSignUpListRequest request = mock(AdminSignUpListRequest.class); + Page expectedPage = new PageImpl<>(Collections.emptyList()); + + when(adminSignUpService.getAdminSignUpList(request)).thenReturn(expectedPage); + + Page result = adminFacade.getSignUpRequestList(request); + + assertEquals(expectedPage, result); + verify(adminSignUpService).getAdminSignUpList(request); + } + + @DisplayName("acceptAdminSignUp 메서드가 AdminSignUpService를 잘 호출하는지") + @Test + void acceptAdminSignUp_ShouldCallService() { + SignUpAcceptRequest request = mock(SignUpAcceptRequest.class); + + doNothing().when(adminSignUpService).acceptAdminSignUp(request); + + adminFacade.acceptAdminSignUp(request); + + verify(adminSignUpService).acceptAdminSignUp(request); + } + + @DisplayName("rejectAdminSignUp 메서드가 AdminSignUpService를 잘 호출하는지") + @Test + void rejectAdminSignUp_ShouldCallService() { + SignUpRejectRequest request = mock(SignUpRejectRequest.class); + + doNothing().when(adminSignUpService).rejectAdminSignUp(request); + + adminFacade.rejectAdminSignUp(request); + + verify(adminSignUpService).rejectAdminSignUp(request); + } + + @DisplayName("signIn 메서드가 JwtToken을 잘 반환하는지") + @Test + void signIn_ShouldReturnJwtToken() throws Exception { + LoginRequest command = mock(LoginRequest.class); + JwtToken expectedToken = new JwtToken("mockType", "mockAccessToken", "mockRefreshToken"); + + when(adminService.signIn(command)).thenReturn(expectedToken); + + JwtToken result = adminFacade.signIn(command); + + assertEquals(expectedToken, result); + verify(adminService).signIn(command); + } + + @DisplayName("getAdminInfoByEmail 메서드가 AdminInfoResponse를 잘 반환하는지") + @Test + void getAdminInfoByEmail_ShouldReturnAdminInfoResponse() { + String email = "test@test.com"; + AdminInfoResponse expectedResponse = mock(AdminInfoResponse.class); + + when(adminService.getAdminInfoByEmail(email)).thenReturn(expectedResponse); + + AdminInfoResponse result = adminFacade.getAdminInfoByEmail(email); + + assertEquals(expectedResponse, result); + verify(adminService).getAdminInfoByEmail(email); + } + + @DisplayName("getAdminDetailInfoByEmail 메서드가 AdminDetailInfoResponse를 잘 반환하는지") + @Test + void getAdminDetailInfoByEmail_ShouldReturnAdminDetailInfoResponse() { + String email = "test@test.com"; + AdminDetailInfoResponse expectedResponse = mock(AdminDetailInfoResponse.class); + + when(adminService.getAdminDetailInfoByEmail(email)).thenReturn(expectedResponse); + + AdminDetailInfoResponse result = adminFacade.getAdminDetailInfoByEmail(email); + + assertEquals(expectedResponse, result); + verify(adminService).getAdminDetailInfoByEmail(email); + } + + @DisplayName("updateProfileImage 메서드가 AdminService를 잘 호출하는지") + @Test + void updateProfileImage_ShouldCallService() { + String email = "test@test.com"; + MultipartFile mockFile = mock(MultipartFile.class); + + adminFacade.updateProfileImage(email, mockFile); + + verify(adminService).updateProfileImage(email, mockFile); + } + + @DisplayName("updatePassword 메서드가 AdminService를 잘 호출하는지") + @Test + void updatePassword_ShouldCallService() { + String email = "test@test.com"; + AdminCommand.AdminPasswordUpdateRequest command = AdminCommand.AdminPasswordUpdateRequest.builder() + .existingPassword("oldPassword") + .newPassword("newPassword") + .build(); + + adminFacade.updatePassword(email, command); + + verify(adminService).updatePassword(email, command); + } + @DisplayName("testSignUp 메서드가 AdminService를 잘 호출하는지") + @Test + void testSignUp_ShouldCallService() { + doNothing().when(adminService).testSignUp(); + + adminFacade.testSignUp(); + + verify(adminService).testSignUp(); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/domain/admin/AdminServiceImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/domain/admin/AdminServiceImplTest.java new file mode 100644 index 000000000..11abe243c --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/domain/admin/AdminServiceImplTest.java @@ -0,0 +1,170 @@ +package kr.co.yigil.admin.domain.admin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.domain.admin.AdminCommand.LoginRequest; +import kr.co.yigil.auth.application.JwtTokenProvider; +import kr.co.yigil.auth.dto.JwtToken; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.domain.FileUploader; +import kr.co.yigil.global.exception.AuthException; +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.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +public class AdminServiceImplTest { + + @Mock + private AdminReader adminReader; + @Mock + private AdminStore adminStore; + @Mock + private AuthenticationManager authenticationManager; + @Mock + private JwtTokenProvider jwtTokenProvider; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private FileUploader fileUploader; + + @InjectMocks + private AdminServiceImpl adminService; + + @DisplayName("signIn 메서드가 JwtToken을 잘 반환하는지") + @Test + void signIn_ShouldReturnJwtToken() { + AdminCommand.LoginRequest command = new LoginRequest("test@test.com", "password"); + Authentication authentication = mock(Authentication.class); + JwtToken expectedToken = new JwtToken("mockType", "mockAccessToken", "mockRefreshToken"); + + when(authenticationManager.authenticate( + any(UsernamePasswordAuthenticationToken.class))).thenReturn(authentication); + when(jwtTokenProvider.generateToken(authentication)).thenReturn(expectedToken); + + JwtToken result = adminService.signIn(command); + + assertEquals(expectedToken, result); + verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(jwtTokenProvider).generateToken(authentication); + } + + @DisplayName("getAdminInfoByEmail 메서드가 AdminInfoResponse를 잘 반환하는지") + @Test + void getAdminInfoByEmail_ShouldReturnAdminInfoResponse() { + String email = "test@test.com"; + String expectedName = "Test Admin"; + Admin admin = mock(Admin.class); + + when(admin.getNickname()).thenReturn(expectedName); + when(adminReader.getAdminByEmail(email)).thenReturn(admin); + + AttachFile attachFile = mock(AttachFile.class); + when(attachFile.getFileUrl()).thenReturn("http://test.com"); + when(admin.getProfileImage()).thenReturn(attachFile); + + AdminInfo.AdminInfoResponse result = adminService.getAdminInfoByEmail(email); + + assertEquals(expectedName, result.getNickname()); + + verify(adminReader).getAdminByEmail(email); + } + + @DisplayName("getAdminDetailInfoByEmail 메서드가 AdminDetailInfoResponse를 잘 반환하는지") + @Test + void getAdminDetailInfoByEmail_ShouldReturnAdminDetailInfoResponse() { + String email = "test@test.com"; + String expectedName = "Test Admin"; + Admin admin = mock(Admin.class); + + when(admin.getNickname()).thenReturn(expectedName); + when(adminReader.getAdminByEmail(email)).thenReturn(admin); + + AttachFile attachFile = mock(AttachFile.class); + when(attachFile.getFileUrl()).thenReturn("http://test.com"); + when(admin.getProfileImage()).thenReturn(attachFile); + + AdminInfo.AdminDetailInfoResponse result = adminService.getAdminDetailInfoByEmail(email); + + assertEquals(expectedName, result.getNickname()); + + verify(adminReader).getAdminByEmail(email); + } + + @DisplayName("updateProfileImage 메서드가 Admin을 잘 업데이트하는지") + @Test + void updateProfileImage_ShouldUpdateAdmin() { + Admin admin = mock(Admin.class); + MultipartFile profileImageFile = mock(MultipartFile.class); + AttachFile adminAttachFile = mock(AttachFile.class); + when(adminReader.getAdminByEmail(anyString())).thenReturn(admin); + when(fileUploader.upload(any())).thenReturn(adminAttachFile); + + adminService.updateProfileImage("email", profileImageFile); + + verify(admin).updateProfileImage(any()); + } + + @DisplayName("updatePassword 메서드의 파라미터로 올바른 existingPassword 입력시 Admin password를 잘 업데이트하는지") + @Test + void updatePassword_ShouldUpdateAdmin() { + Admin admin = mock(Admin.class); + AdminCommand.AdminPasswordUpdateRequest command = AdminCommand.AdminPasswordUpdateRequest.builder() + .existingPassword("oldPassword") + .newPassword("newPassword") + .build(); + when(adminReader.getAdminByEmail(anyString())).thenReturn(admin); + when(admin.getPassword()).thenReturn("existingencodedPassword"); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + + adminService.updatePassword("email", command); + + verify(admin).updatePassword(anyString()); + } + + @DisplayName("updatePassword 메서드가 기존 비밀번호가 틀렸을 때 예외를 잘 던지는지") + @Test + void updatePassword_ShouldThrowException_WhenExistingPasswordIsIncorrect() { + Admin admin = mock(Admin.class); + AdminCommand.AdminPasswordUpdateRequest command = AdminCommand.AdminPasswordUpdateRequest.builder() + .existingPassword("wrongOldPassword") + .newPassword("newPassword") + .build(); + when(adminReader.getAdminByEmail(anyString())).thenReturn(admin); + when(admin.getPassword()).thenReturn("existingencodedPassword"); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); + + assertThrows(AuthException.class, () -> { + adminService.updatePassword("email", command); + }); + + verify(admin, never()).updatePassword(anyString()); + } + + @Test + void testSignUp_ShouldStoreAdmin() { + Admin admin = mock(Admin.class); + when(passwordEncoder.encode("0000")).thenReturn("encodedPassword"); + + adminService.testSignUp(); + + verify(adminStore).store(any(Admin.class)); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/domain/adminSignUp/AdminPasswordGeneratorTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/domain/adminSignUp/AdminPasswordGeneratorTest.java new file mode 100644 index 000000000..0d5357314 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/domain/adminSignUp/AdminPasswordGeneratorTest.java @@ -0,0 +1,21 @@ +package kr.co.yigil.admin.domain.adminSignUp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import kr.co.yigil.admin.infrastructure.adminSignUp.AdminPasswordGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class AdminPasswordGeneratorTest { + + @DisplayName("생성된 비밀번호가 길이 제한을 만족하는지") + @Test + void generateRandomPassword_LengthCheck() { + AdminPasswordGenerator generator = new AdminPasswordGenerator(); + String password = generator.generateRandomPassword(); + + assertEquals(10, password.length()); + } + + +} diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpServiceImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpServiceImplTest.java new file mode 100644 index 000000000..dcc9872c0 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/domain/adminSignUp/AdminSignUpServiceImplTest.java @@ -0,0 +1,121 @@ +package kr.co.yigil.admin.domain.adminSignUp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.admin.AdminReader; +import kr.co.yigil.admin.domain.admin.AdminStore; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpCommand.AdminSignUpRequest; +import kr.co.yigil.admin.infrastructure.adminSignUp.AdminPasswordGenerator; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignUpListRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpAcceptRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpRejectRequest; +import kr.co.yigil.global.exception.BadRequestException; +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.PageRequest; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +public class AdminSignUpServiceImplTest { + + @Mock + private AdminSignUpReader adminSignUpReader; + @Mock + private AdminSignUpStore adminSignUpStore; + @Mock + private EmailSender emailSender; + @Mock + private AdminReader adminReader; + @Mock + private AdminPasswordGenerator adminPasswordGenerator; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private AdminStore adminStore; + + @InjectMocks + private AdminSignUpServiceImpl adminSignUpService; + + @DisplayName("sendSignUpRequest 메서드가 AdminSignUp을 잘 저장하는지") + @Test + void sendSignUpRequest_ShouldStoreAdminSignUp() { + AdminSignUpCommand.AdminSignUpRequest command = new AdminSignUpRequest("email", "nickname"); + + when(adminReader.existsByEmailOrNickname(anyString(), anyString())).thenReturn(false); + adminSignUpService.sendSignUpRequest(command); + + verify(adminSignUpStore).store(any(AdminSignUp.class)); + } + + @DisplayName("sendSignUpRequest 메서드가 이미 가입된 이메일이나 닉네임이 있을 때 BadRequestException을 던지는지") + @Test + void sendSignUpRequest_ShouldThrowBadRequestException() { + AdminSignUpCommand.AdminSignUpRequest command = new AdminSignUpRequest("email", "nickname"); + + when(adminReader.existsByEmailOrNickname(anyString(), anyString())).thenReturn(true); + + assertThrows(BadRequestException.class, () -> adminSignUpService.sendSignUpRequest(command)); + } + + @DisplayName("getAdminSignUpList 메서드가 AdminSignUp 리스트를 잘 반환하는지") + @Test + void getAdminSignUpList_ShouldReturnAdminSignUpList() { + AdminSignUpListRequest request = new AdminSignUpListRequest(1, 10); + Page expectedPage = mock(Page.class); + + when(adminSignUpReader.findAll(any(PageRequest.class))).thenReturn(expectedPage); + + Page result = adminSignUpService.getAdminSignUpList(request); + + assertEquals(expectedPage, result); + verify(adminSignUpReader).findAll(any(PageRequest.class)); + } + + @DisplayName("acceptAdminSignUp 메서드가 AdminSignUp을 잘 수락하는지") + @Test + void acceptAdminSignUp_ShouldAcceptAdminSignUp() { + SignUpAcceptRequest request = new SignUpAcceptRequest(List.of(1L, 2L)); + + when(adminPasswordGenerator.generateRandomPassword()).thenReturn("password"); + AdminSignUp adminSignUp1 = new AdminSignUp("email@email.com", "nickname"); + AdminSignUp adminSignUp2 = new AdminSignUp("email2@email.com", "nickname2"); + when(adminSignUpReader.findById(1L)).thenReturn(adminSignUp1); + when(adminSignUpReader.findById(2L)).thenReturn(adminSignUp2); + + adminSignUpService.acceptAdminSignUp(request); + + verify(adminStore, times(request.getIds().size())).store(any(Admin.class)); + verify(emailSender, times(request.getIds().size())).sendAcceptEmail(any(AdminSignUp.class), anyString()); + verify(adminSignUpStore, times(request.getIds().size())).remove(any(AdminSignUp.class)); + } + + @DisplayName("rejectAdminSignUp 메서드가 AdminSignUp을 잘 거절하는지") + @Test + void rejectAdminSignUp_ShouldRejectAdminSignUp() { + SignUpRejectRequest request = new SignUpRejectRequest(List.of(1L, 2L)); + AdminSignUp adminSignUp1 = new AdminSignUp("email@email.com", "nickname"); + AdminSignUp adminSignUp2 = new AdminSignUp("email2@email.com", "nickname2"); + when(adminSignUpReader.findById(1L)).thenReturn(adminSignUp1); + when(adminSignUpReader.findById(2L)).thenReturn(adminSignUp2); + + adminSignUpService.rejectAdminSignUp(request); + + verify(emailSender, times(request.getIds().size())).sendRejectEmail(any(AdminSignUp.class)); + verify(adminSignUpStore, times(request.getIds().size())).remove(any(AdminSignUp.class)); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/admin/AdminReaderImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/admin/AdminReaderImplTest.java new file mode 100644 index 000000000..1f8b42dda --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/admin/AdminReaderImplTest.java @@ -0,0 +1,69 @@ +package kr.co.yigil.admin.infrastructure.admin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.infrastructure.AdminRepository; +import kr.co.yigil.global.exception.BadRequestException; +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; + +@ExtendWith(MockitoExtension.class) +class AdminReaderImplTest { + + @Mock + private AdminRepository adminRepository; + + @InjectMocks + private AdminReaderImpl adminReader; + + @DisplayName("existsByEmailOrNickname 메서드가 존재하는 이메일과 닉네임을 잘 반환하는지") + @Test + void existsByEmailOrNickname_ShouldReturnTrue_WhenEmailAndNicknameExist() { + String email = "test@test.com"; + String nickname = "test"; + + when(adminRepository.existsByEmailOrNickname(email, nickname)).thenReturn(true); + + boolean result = adminReader.existsByEmailOrNickname(email, nickname); + + assertTrue(result); + verify(adminRepository).existsByEmailOrNickname(email, nickname); + } + + @DisplayName("getAdminByEmail 메서드가 Admin을 잘 반환하는지") + @Test + void getAdminByEmail_ShouldReturnAdmin() { + String email = "test@test.com"; + Admin admin = mock(Admin.class); + + when(adminRepository.findByEmail(email)).thenReturn(Optional.of(admin)); + + Admin result = adminReader.getAdminByEmail(email); + + assertEquals(admin, result); + verify(adminRepository).findByEmail(email); + } + + @DisplayName("getAdminByEmail 메서드가 Admin이 없을 때 예외를 잘 발생시키는지") + @Test + void getAdminByEmail_ShouldThrowException_WhenAdminNotFound() { + String email = "test@test.com"; + + when(adminRepository.findByEmail(email)).thenReturn(Optional.empty()); + + assertThrows(BadRequestException.class, () -> adminReader.getAdminByEmail(email)); + verify(adminRepository).findByEmail(email); + } + +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/admin/AdminStoreImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/admin/AdminStoreImplTest.java new file mode 100644 index 000000000..fad6f2939 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/admin/AdminStoreImplTest.java @@ -0,0 +1,33 @@ +package kr.co.yigil.admin.infrastructure.admin; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.infrastructure.AdminRepository; +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; + +@ExtendWith(MockitoExtension.class) +class AdminStoreImplTest { + + @Mock + private AdminRepository adminRepository; + + @InjectMocks + private AdminStoreImpl adminStore; + + + @DisplayName("store 메서드가 Admin을 잘 저장하는지") + @Test + void store_ShouldStoreAdmin() { + Admin admin = mock(Admin.class); + adminStore.store(admin); + + verify(adminRepository).save(admin); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpReaderImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpReaderImplTest.java new file mode 100644 index 000000000..851dedd5d --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpReaderImplTest.java @@ -0,0 +1,70 @@ +package kr.co.yigil.admin.infrastructure.adminSignUp; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.infrastructure.AdminSignUpRepository; +import kr.co.yigil.global.exception.BadRequestException; +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.PageRequest; +import org.springframework.data.domain.Pageable; + + +@ExtendWith(MockitoExtension.class) +class AdminSignUpReaderImplTest { + + @Mock + private AdminSignUpRepository adminSignUpRepository; + + @InjectMocks + private AdminSignUpReaderImpl adminSignUpReader; + + @DisplayName("findAll 메서드가 AdminSignUp 페이지를 잘 반환하는지") + @Test + void findAll_ShouldReturnPageOfAdminSignUp() { + Pageable pageable = PageRequest.of(0, 10); + Page expectedPage = mock(Page.class); + + when(adminSignUpRepository.findAll(pageable)).thenReturn(expectedPage); + + Page result = adminSignUpReader.findAll(pageable); + + assertEquals(expectedPage, result); + verify(adminSignUpRepository).findAll(pageable); + } + + @DisplayName("findById 메서드가 AdminSignUp을 잘 반환하는지") + @Test + void findById_ShouldReturnAdminSignUp() { + Long id = 1L; + AdminSignUp adminSignUp = mock(AdminSignUp.class); + + when(adminSignUpRepository.findById(id)).thenReturn(Optional.of(adminSignUp)); + + AdminSignUp result = adminSignUpReader.findById(id); + + assertEquals(adminSignUp, result); + verify(adminSignUpRepository).findById(id); + } + + @DisplayName("findById 메서드가 AdminSignUp이 없을 때 예외를 잘 발생시키는지") + @Test + void findById_ShouldThrowException_WhenAdminSignUpNotFound() { + Long id = 1L; + + when(adminSignUpRepository.findById(id)).thenReturn(Optional.empty()); + + assertThrows(BadRequestException.class, () -> adminSignUpReader.findById(id)); + verify(adminSignUpRepository).findById(id); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpStoreImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpStoreImplTest.java new file mode 100644 index 000000000..269541eed --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/adminSignUp/AdminSignUpStoreImplTest.java @@ -0,0 +1,44 @@ +package kr.co.yigil.admin.infrastructure.adminSignUp; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.infrastructure.AdminSignUpRepository; +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; + + +@ExtendWith(MockitoExtension.class) +class AdminSignUpStoreImplTest { + + @Mock + private AdminSignUpRepository adminSignUpRepository; + + @InjectMocks + private AdminSignUpStoreImpl adminSignUpStore; + + @DisplayName("store 메서드가 AdminSignUp을 잘 저장하는지") + @Test + void store_ShouldStoreAdminSignUp() { + AdminSignUp adminSignUp = mock(AdminSignUp.class); + + adminSignUpStore.store(adminSignUp); + + verify(adminSignUpRepository).save(adminSignUp); + } + + @DisplayName("remove 메서드가 AdminSignUp을 잘 삭제하는지") + @Test + void remove_ShouldRemoveAdminSignUp() { + AdminSignUp adminSignUp = mock(AdminSignUp.class); + + adminSignUpStore.remove(adminSignUp); + + verify(adminSignUpRepository).delete(adminSignUp); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/adminSignUp/EmailSenderImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/adminSignUp/EmailSenderImplTest.java new file mode 100644 index 000000000..29f68930c --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/infrastructure/adminSignUp/EmailSenderImplTest.java @@ -0,0 +1,59 @@ +package kr.co.yigil.admin.infrastructure.adminSignUp; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.email.EmailSendEvent; +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.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + + +@ExtendWith(MockitoExtension.class) +class EmailSenderImplTest { + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private EmailSenderImpl emailSender; + + @Captor + private ArgumentCaptor eventCaptor; + + @BeforeEach + void setUp() { + // Initialize your setup here + } + + @DisplayName("sendAcceptEmail 메서드가 이메일 이벤트를 잘 발행하는지") + @Test + void sendAcceptEmail_ShouldPublishEvent() { + AdminSignUp signUp = mock(AdminSignUp.class); + String password = "password"; + + emailSender.sendAcceptEmail(signUp, password); + + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + } + + @DisplayName("sendRejectEmail 메서드가 이메일 이벤트를 잘 발행하는지") + @Test + void sendRejectEmail_ShouldPublishEvent() { + AdminSignUp signUp = mock(AdminSignUp.class); + + emailSender.sendRejectEmail(signUp); + + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/admin/interfaces/AdminApiControllerTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/interfaces/AdminApiControllerTest.java new file mode 100644 index 000000000..4ec35fff8 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/admin/interfaces/AdminApiControllerTest.java @@ -0,0 +1,205 @@ +package kr.co.yigil.admin.interfaces; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.yigil.admin.application.AdminFacade; +import kr.co.yigil.admin.domain.AdminSignUp; +import kr.co.yigil.admin.domain.admin.AdminCommand; +import kr.co.yigil.admin.domain.admin.AdminInfo; +import kr.co.yigil.admin.domain.adminSignUp.AdminSignUpCommand; +import kr.co.yigil.admin.interfaces.controller.AdminApiController; +import kr.co.yigil.admin.interfaces.dto.mapper.AdminMapper; +import kr.co.yigil.admin.interfaces.dto.mapper.AdminSignupMapper; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignUpListRequest; +import kr.co.yigil.admin.interfaces.dto.request.AdminSignupRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpAcceptRequest; +import kr.co.yigil.admin.interfaces.dto.request.SignUpRejectRequest; +import kr.co.yigil.admin.interfaces.dto.response.AdminDetailInfoResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminInfoResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminSignUpsResponse; +import kr.co.yigil.admin.interfaces.dto.response.AdminSignupResponse; +import kr.co.yigil.auth.dto.JwtToken; +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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith(SpringExtension.class) +@WebMvcTest(AdminApiController.class) +public class AdminApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private AdminFacade adminFacade; + + @MockBean + private AdminMapper adminMapper; + + @MockBean + private AdminSignupMapper adminSignupMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @DisplayName("회원가입이 요청 되었을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenSendSignUpRequest_thenReturns200AndAdminSignupResponse() throws Exception { + AdminSignupRequest request = new AdminSignupRequest(); + AdminSignupResponse response = new AdminSignupResponse(); + AdminSignUpCommand.AdminSignUpRequest command = mock( + AdminSignUpCommand.AdminSignUpRequest.class); + given(adminSignupMapper.toCommand(request)).willReturn(command); + + mockMvc.perform(post("/api/v1/admins/signup") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"test@test.com\", \"nickname\": \"TestUser\"}")) + .andExpect(status().isOk()); + } + + + @DisplayName("회원가입 요청 리스트가 요청 되었을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenGetSignUpRequestList_thenReturns200AndAdminSignUpsResponse() throws Exception { + AdminSignUpListRequest request = new AdminSignUpListRequest(); + Page adminSignUps = Page.empty(); + AdminSignUpsResponse response = new AdminSignUpsResponse(); + + given(adminFacade.getSignUpRequestList(request)).willReturn(adminSignUps); + given(adminSignupMapper.toResponse(adminSignUps)).willReturn(response); + + mockMvc.perform(get("/api/v1/admins/signup/list") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @DisplayName("회원가입이 승인 되었을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenAcceptSignUp_thenReturns200AndSignUpAcceptResponse() throws Exception { + SignUpAcceptRequest request = new SignUpAcceptRequest(); + + mockMvc.perform(post("/api/v1/admins/signup/accept") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @DisplayName("회원가입이 거절 되었을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenRejectSignUp_thenReturns200AndSignUpRejectResponse() throws Exception { + SignUpRejectRequest request = new SignUpRejectRequest(); + + mockMvc.perform(post("/api/v1/admins/signup/reject") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @DisplayName("로그인이 요청 되었을 때 200 응답과 Jwt 토큰이 잘 반환되는지") + @Test + void whenLogin_thenReturns200AndJwtToken() throws Exception { + AdminCommand.LoginRequest command = mock(AdminCommand.LoginRequest.class); + JwtToken token = new JwtToken("mockType", "mockAccessToken", "mockRefreshToken"); + + given(adminFacade.signIn(command)).willReturn(token); + + mockMvc.perform(post("/api/v1/admins/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"test@test,com\", \"password\": \"testPassword\"}")) + .andExpect(status().isOk()); + } + + @DisplayName("어드민 정보가 요청 되었을 때 200 응답과 response가 잘 반환되는지") + @Test + @WithMockUser(username = "tester@tester.com") + void whenGetMemberInfo_thenReturns200AndAdminInfoResponse() throws Exception { + AdminInfo.AdminInfoResponse response = mock(AdminInfo.AdminInfoResponse.class); + + given(response.getNickname()).willReturn("tester"); + given(response.getProfileUrl()).willReturn("example.url"); + + given(adminFacade.getAdminInfoByEmail("tester@tester.com")).willReturn(response); + given(adminMapper.toResponse(response)).willReturn( + new AdminInfoResponse("tester", "example.url")); + + mockMvc.perform(get("/api/v1/admins/info") + .with(SecurityMockMvcRequestPostProcessors.user("tester").roles("USER")) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nickname").value(response.getNickname())) + .andExpect(jsonPath("$.profile_url").value(response.getProfileUrl())); + } + + @DisplayName("어드민 상세 정보가 요청 되었을 때 200 응답과 response가 잘 반환되는지") + @Test + @WithMockUser(username = "tester@tester.com") + void whenGetMemberDetailInfo_thenReturns200AndAdminDetailInfoResponse() throws Exception { + AdminInfo.AdminDetailInfoResponse response = mock(AdminInfo.AdminDetailInfoResponse.class); + + given(response.getNickname()).willReturn("tester"); + given(response.getProfileUrl()).willReturn("example.url"); + given(response.getEmail()).willReturn("tester@tester.com"); + given(response.getPassword()).willReturn("password"); + + given(adminFacade.getAdminDetailInfoByEmail("tester@tester.com")).willReturn(response); + given(adminMapper.toResponse(response)).willReturn( + new AdminDetailInfoResponse("tester", "example.url", "tester@tester.com", + "password")); + + mockMvc.perform(get("/api/v1/admins/detail-info") + .with(SecurityMockMvcRequestPostProcessors.user("tester").roles("USER")) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nickname").value(response.getNickname())) + .andExpect(jsonPath("$.email").value(response.getEmail())) + .andExpect(jsonPath("$.password").value(response.getPassword())) + .andExpect(jsonPath("$.profile_url").value(response.getProfileUrl())); + } + + @DisplayName("어드민 프로필 이미지가 수정 되었을 때 200 응답과 response가 잘 반환되는지") + @Test + @WithMockUser(username = "tester@tester.com") + void whenUpdateProfileImage_thenReturns200AndAdminProfileImageUpdateResponse() throws Exception { + + mockMvc.perform(post("/api/v1/admins/profile-image") + .with(SecurityMockMvcRequestPostProcessors.user("tester").roles("USER")) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()); + + verify(adminFacade).updateProfileImage(anyString(), any()); + } + + @DisplayName("어드민 비밀번호가 수정 되었을 때 200 응답과 response가 잘 반환되는지") + @Test + @WithMockUser(username = "tester@tester.com") + void whenUpdatePassword_thenReturns200AndAdminPasswordUpdateResponse() throws Exception { + mockMvc.perform(post("/api/v1/admins/password") + .with(SecurityMockMvcRequestPostProcessors.user("tester").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"existingPassword\": \"testPassword\", \"newPassword\": \"newPassword\"}")) + .andExpect(status().isOk()); + + verify(adminFacade).updatePassword(anyString(), any()); + } +} diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/comment/application/CommentFacadeTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/application/CommentFacadeTest.java new file mode 100644 index 000000000..b830f4666 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/application/CommentFacadeTest.java @@ -0,0 +1,85 @@ +package kr.co.yigil.comment.application; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.admin.domain.admin.AdminService; +import kr.co.yigil.comment.domain.CommentInfo.ChildrenPageComments; +import kr.co.yigil.comment.domain.CommentInfo.CommentList; +import kr.co.yigil.comment.domain.CommentInfo.ParentPageComments; +import kr.co.yigil.comment.domain.CommentService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +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; + +@ExtendWith(MockitoExtension.class) +class CommentFacadeTest { + + @Mock + private CommentService commentService; + @Mock + private NotificationService notificationService; + @Mock + private AdminService adminService; + + @InjectMocks + private CommentFacade commentFacade; + + + @DisplayName("getParentComments 메서드가 ParentPageComments를 잘 반환하는지") + @Test + void whenGetParentComments_thenShouldReturnParentPagecomment() { + when(commentService.getParentComments(anyLong(), any(PageRequest.class))).thenReturn(mock(ParentPageComments.class)); + + var response = commentFacade.getParentComments(1L, PageRequest.of(0, 10)); + + assertThat(response).isInstanceOf(ParentPageComments.class); + } + + @DisplayName("getChildrenComments 메서드가 ChildrenPageComments를 잘 반환하는지") + @Test + void whenGetChildrenComments_thenShouldReturnChildrenPageComments() { + + when(commentService.getChildrenComments(anyLong(), any(PageRequest.class))).thenReturn(mock( + ChildrenPageComments.class)); + + var response = commentFacade.getChildrenComments(1L, PageRequest.of(0, 10)); + + assertThat(response).isInstanceOf(ChildrenPageComments.class); + } + + @DisplayName("deleteComment 메서드가 잘 호출되는지") + @Test + void whenDeleteComment_thenShoudSendNotification() { + + Long adminId = 1L; + Long memberId = 3L; + Long commentId = 2L; + when(commentService.deleteComment(anyLong())).thenReturn(memberId); + when(adminService.getAdminId()).thenReturn(adminId); + + commentFacade.deleteComment(commentId); + + verify(notificationService).sendNotification(NotificationType.COMMENT_DELETE, adminId, memberId); + } + + @DisplayName("getComments 메서드가 잘 호출되는지") + @Test + void whenGetComments_thenShouldReturnCommentList() { + when(commentService.getComments(anyLong(), any(PageRequest.class))).thenReturn(mock(CommentList.class)); + + var response = commentFacade.getComments(1L, PageRequest.of(0, 10)); + + assertThat(response).isInstanceOf(CommentList.class); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/comment/domain/CommentServiceImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/domain/CommentServiceImplTest.java new file mode 100644 index 000000000..37cc11067 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/domain/CommentServiceImplTest.java @@ -0,0 +1,130 @@ +package kr.co.yigil.comment.domain; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.comment.domain.CommentInfo.ChildrenPageComments; +import kr.co.yigil.comment.domain.CommentInfo.CommentList; +import kr.co.yigil.comment.domain.CommentInfo.ParentPageComments; +import kr.co.yigil.member.Member; +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; + +@ExtendWith(MockitoExtension.class) +class CommentServiceImplTest { + + @Mock + private CommentReader commentReader; + + @Mock + private CommentStore commentStore; + + @InjectMocks + private CommentServiceImpl commentServiceImpl; + + + @DisplayName("getParentComments 메서드가 CommentReader를 잘 호출하고 ParentPageComments를 반환하는지") + @Test + void whenGetParentComments_thenShouldReturnParentPageComments() { + + Long travelId = 1L; + PageRequest pageRequest = PageRequest.of(0, 5); + Member mockMember = new Member(1L, null, null, "nickname", null, null); + Comment mockComment = mock(Comment.class); + when(mockComment.getId()).thenReturn(1L); + when(mockComment.getMember()).thenReturn(mockMember); + when(mockComment.getContent()).thenReturn("content"); + when(mockComment.getCreatedAt()).thenReturn(null); + List comments = List.of(mockComment); + Page pageComments = new PageImpl<>(comments, pageRequest, comments.size()); + + when(commentReader.getParentComments(travelId, pageRequest)).thenReturn(pageComments); + when(commentReader.getChildrenCount(anyLong())).thenReturn(1); + + // When + ParentPageComments response = commentServiceImpl.getParentComments(travelId, pageRequest); + + // Then + assertThat(response).isInstanceOf(ParentPageComments.class); + } + + @DisplayName("getChildrenComments 메서드가 CommentReader를 잘 호출하고 ChildrenPageComments를 반환하는지") + @Test + void whenGetChildrenComments_thenShouldReturnChildrenPageComments() { + + Long parentId = 1L; + PageRequest pageRequest = PageRequest.of(0, 5); + Member mockMember = new Member(1L, null, null, "nickname", null, null); + Comment mockComment = mock(Comment.class); + when(mockComment.getId()).thenReturn(1L); + when(mockComment.getMember()).thenReturn(mockMember); + when(mockComment.getContent()).thenReturn("content"); + when(mockComment.getCreatedAt()).thenReturn(null); + List comments = List.of(mockComment); + Page pageComments = new PageImpl<>(comments, pageRequest, comments.size()); + + when(commentReader.getChildrenComments(parentId, pageRequest)).thenReturn(pageComments); + + // When + CommentInfo.ChildrenPageComments response = commentServiceImpl.getChildrenComments(parentId, + pageRequest); + + // Then + assertThat(response).isInstanceOf(ChildrenPageComments.class); + + } + + @DisplayName("deleteComment 메서드가 CommentReader를 잘 호출하고 memberId를 반환하는지") + @Test + void whenDeleteComment_thenShouldReturnMemberId() { + + Long commentId = 1L; + Member mockMember = new Member(1L, null, null, "nickname", null, null); + Comment mockComment = mock(Comment.class); + + when(mockComment.getMember()).thenReturn(mockMember); + when(commentReader.getComment(commentId)).thenReturn(mockComment); + + // When + Long response = commentServiceImpl.deleteComment(commentId); + + // Then + assertThat(response).isEqualTo(1L); + + } + + @DisplayName("getComments 메서드가 CommentReader를 잘 호출하고 CommentList를 반환하는지") + @Test + void whenGetComments_thenShouldReturnCommentList() { + + Long travelId = 1L; + PageRequest pageRequest = PageRequest.of(0, 5); + Member mockMember = mock(Member.class); + Comment mockComment = mock(Comment.class); + when(mockComment.getId()).thenReturn(1L); + when(mockComment.getMember()).thenReturn(mockMember); + when(mockComment.getContent()).thenReturn("content"); + when(mockComment.getCreatedAt()).thenReturn(null); + when(mockMember.getId()).thenReturn(1L); + List comments = List.of(mockComment); + Page pageComments = new PageImpl<>(comments, pageRequest, comments.size()); + + when(commentReader.getParentComments(travelId, pageRequest)).thenReturn(pageComments); + when(commentReader.getChildrenComments(1L)).thenReturn(List.of(mockComment)); + + CommentList response = commentServiceImpl.getComments(travelId, pageRequest); + + assertThat(response).isInstanceOf(CommentList.class); + } + +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/comment/infrastructure/CommentReaderImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/infrastructure/CommentReaderImplTest.java new file mode 100644 index 000000000..92447b1db --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/infrastructure/CommentReaderImplTest.java @@ -0,0 +1,115 @@ +package kr.co.yigil.comment.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.comment.domain.Comment; +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; + +@ExtendWith(MockitoExtension.class) +class CommentReaderImplTest { + + @Mock + private CommentRepository commentRepository; + + @InjectMocks + private CommentReaderImpl commentReader; + + + @DisplayName("댓글 수를 조회할 때 댓글 수를 잘 반환하는지") + @Test + void whenGetCommentCount_shouldReturnCommentcount() { + + int commentCount = 3; + when(commentRepository.countAllByTravelIdAndIsDeletedFalse(1L)).thenReturn(commentCount); + + var result = commentReader.getCommentCount(1L); + + assertThat(result).isEqualTo(commentCount); + } + + @DisplayName("댓글을 조회할 때 댓글을 잘 반환하는지") + @Test + void whenGetParentComments_thenShouldReturnPageComment() { + Comment comment1 = mock(Comment.class); + Comment comment2 = mock(Comment.class); + List comments = List.of(comment1, comment2); + + Page pageComments = new PageImpl<>(comments); + + when(commentRepository.findAllAsPageImplByTravelIdAndParentIdIsNull(1L, null)).thenReturn( + pageComments); + + var result = commentReader.getParentComments(1L, null); + + assertThat(result).isEqualTo(pageComments); + } + + @DisplayName("대댓글을 조회할 때 대댓글을 잘 반환하는지") + @Test + void whenGetChildrenComments_thenShouldReturnPageComment() { + + Comment comment1 = mock(Comment.class); + Comment comment2 = mock(Comment.class); + List comments = List.of(comment1, comment2); + + Page pageComments = new PageImpl<>(comments); + + when(commentRepository.findAllByParentIdAndIsDeletedFalse(anyLong(), + any(PageRequest.class))).thenReturn(pageComments); + + var result = commentReader.getChildrenComments(1L, mock(PageRequest.class)); + + assertThat(result).isEqualTo(pageComments); + } + + @DisplayName("대댓글 수를 조회할 때 대댓글 수를 잘 반환하는지") + @Test + void whenGetChildrenCount_thenShouldReturnChildrenCount() { + + int childrenCount = 3; + when(commentRepository.countByParentId(1L)).thenReturn(childrenCount); + + var result = commentReader.getChildrenCount(1L); + + assertThat(result).isEqualTo(childrenCount); + } + + @DisplayName("댓글을 조회할 때 댓글을 잘 반환하는지") + @Test + void whenGetComment_thenShouldReturnComment() { + Comment comment = mock(Comment.class); + + when(commentRepository.findById(1L)).thenReturn(java.util.Optional.of(comment)); + + var result = commentReader.getComment(1L); + + assertThat(result).isEqualTo(comment); + } + + @DisplayName("대댓글을 조회할 때 대댓글을 잘 반환하는지") + @Test + void whenGetChildrenComments_thenShouldReturnListComment() { + Comment comment1 = mock(Comment.class); + Comment comment2 = mock(Comment.class); + List comments = List.of(comment1, comment2); + + when(commentRepository.findAllByParentIdAndIsDeletedFalse(1L)).thenReturn(comments); + + var result = commentReader.getChildrenComments(1L); + + assertThat(result).isEqualTo(comments); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/comment/infrastructure/CommentStoreImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/infrastructure/CommentStoreImplTest.java new file mode 100644 index 000000000..537e88f1b --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/infrastructure/CommentStoreImplTest.java @@ -0,0 +1,32 @@ +package kr.co.yigil.comment.infrastructure; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import kr.co.yigil.comment.domain.Comment; +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; + +@ExtendWith(MockitoExtension.class) +class CommentStoreImplTest { + + @Mock + private CommentRepository commentRepository; + @InjectMocks + private CommentStoreImpl commentStore; + + @DisplayName("댓글을 삭제할 때 댓글을 삭제하는지") + @Test + void deleteComment() { + Comment comment = mock(Comment.class); + + commentStore.deleteComment(comment); + + verify(commentRepository).delete(comment); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/comment/infterfaces/controller/CommentApiControllerTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/infterfaces/controller/CommentApiControllerTest.java new file mode 100644 index 000000000..d0c2ca31f --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/comment/infterfaces/controller/CommentApiControllerTest.java @@ -0,0 +1,115 @@ +package kr.co.yigil.comment.infterfaces.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import kr.co.yigil.comment.application.CommentFacade; +import kr.co.yigil.comment.domain.CommentInfo; +import kr.co.yigil.comment.infterfaces.dto.CommentDto; +import kr.co.yigil.comment.infterfaces.dto.mapper.CommentMapper; +import kr.co.yigil.global.SortBy; +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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + + +@ExtendWith(SpringExtension.class) +@WebMvcTest(CommentApiController.class) +class CommentApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private CommentFacade commentFacade; + + @MockBean + private CommentMapper commentMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @DisplayName("댓글 목록을 조회했을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenGetParentCommentList_thenShouldReturn200AndParentCommentsResponse() throws Exception { + + when(commentFacade.getParentComments(1L, + PageRequest.of(0, 5, Sort.by(Sort.Direction.ASC, SortBy.CREATED_AT.getValue())))) + .thenReturn(mock(CommentInfo.ParentPageComments.class)); + when(commentMapper.of(any(CommentInfo.ParentPageComments.class))) + .thenReturn(mock(CommentDto.ParentCommentsResponse.class)); + + mockMvc.perform(get("/api/v1/comments/{travel_id}/parents", 1L) + .param("travel_id", "1") + .param("size", "5") + .param("page", "1")) + .andExpect(status().isOk()); + } + + @DisplayName("대댓글 목록을 조회했을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenGetChildrenCommentList_thenShouldReturn200AndChildrenCommentsResponse() + throws Exception { + + when(commentFacade.getChildrenComments(1L, + PageRequest.of(0, 5, Sort.by(Sort.Direction.ASC, SortBy.CREATED_AT.getValue()))) + ).thenReturn(mock(CommentInfo.ChildrenPageComments.class)); + when(commentMapper.of(any(CommentInfo.ChildrenPageComments.class)) + ).thenReturn(mock(CommentDto.ChildrenCommentsResponse.class)); + + mockMvc.perform(get("/api/v1/comments/{parent_id}/children", 1L) + .param("parent_id", "1") + .param("size", "5") + .param("page", "1")) + .andExpect(status().isOk()); + } + + @DisplayName("댓글을 삭제했을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenDeleteComment_thenShouldReturn200AndString() throws Exception { + // given + Long commentId = 1L; + + // then + mockMvc.perform(delete("/api/v1/comments/{comment_id}", commentId)) + .andExpect(status().isOk()); + + verify(commentFacade).deleteComment(commentId); + } + + @DisplayName("댓글 목록을 조회했을 때 200 응답과 CommentResponse가 잘 조회 되는지") + @Test + void whenGetCommentList_thenReturnOkAndCommentsResponse() throws Exception { + // given + Long travelId = 1L; + + // when + when(commentFacade.getComments(travelId, + PageRequest.of(0, 5, Sort.by(Sort.Direction.ASC, SortBy.CREATED_AT.getValue()))) + ).thenReturn(mock(CommentInfo.CommentList.class)); + when(commentMapper.of(any(CommentInfo.CommentList.class)) + ).thenReturn(mock(CommentDto.CommentsResponse.class)); + + // then + mockMvc.perform(get("/api/v1/comments/{travel_id}", travelId) + .param("travel_id", "1") + .param("size", "5") + .param("page", "1")) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/member/application/MemberFacadeTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/member/application/MemberFacadeTest.java new file mode 100644 index 000000000..905ee93a7 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/member/application/MemberFacadeTest.java @@ -0,0 +1,63 @@ +package kr.co.yigil.member.application; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberService; +import kr.co.yigil.member.interfaces.dto.request.MemberBanRequest; +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.Pageable; + +@ExtendWith(MockitoExtension.class) +class MemberFacadeTest { + @Mock + private MemberService memberService; + + @InjectMocks + private MemberFacade memberFacade; + + @DisplayName("getMemberPage 메서드가 MemberService를 잘 호출하는지") + @Test + void getMemberPage_ShouldCallService() { + Pageable pageable = mock(Pageable.class); + Page expectedPage = new PageImpl<>(Collections.emptyList()); + when(memberService.getMemberPage(pageable)).thenReturn(expectedPage); + + Page memberPage = memberFacade.getMemberPage(pageable); + + assertEquals(expectedPage, memberPage); + verify(memberService).getMemberPage(pageable); + } + + @DisplayName("banMembers 메서드가 MemberService를 잘 호출하는지") + @Test + void banMembers_ShouldCallService() { + MemberBanRequest request = mock(MemberBanRequest.class); + + memberFacade.banMembers(request); + + verify(memberService).banMembers(request); + } + + @DisplayName("unbanMembers 메서드가 MemberService를 잘 호출하는지") + @Test + void unbanMembers_ShouldCallService() { + MemberBanRequest request = mock(MemberBanRequest.class); + + memberFacade.unbanMembers(request); + + verify(memberService).unbanMembers(request); + } + +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/member/domain/MemberServiceImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/member/domain/MemberServiceImplTest.java new file mode 100644 index 000000000..4a7ca49b4 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/member/domain/MemberServiceImplTest.java @@ -0,0 +1,93 @@ +package kr.co.yigil.member.domain; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.MemberStatus; +import kr.co.yigil.member.interfaces.dto.request.MemberBanRequest; +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.Pageable; + +@ExtendWith(MockitoExtension.class) +class MemberServiceImplTest { + @Mock + private MemberReader memberReader; + + @Mock + private MemberStore memberStore; + + @InjectMocks + private MemberServiceImpl memberService; + + @DisplayName("getMemberPage 메서드가 MemberReader를 잘 호출하는지") + @Test + void getMemberPage_ShouldCallReader() { + Pageable pageable = mock(Pageable.class); + memberService.getMemberPage(pageable); + + verify(memberReader).getMemberPageRegardlessOfStatus(pageable); + } + + @DisplayName("banMembers 메서드가 MemberReader와 MemberStore를 잘 호출하는지") + @Test + void banMembers_ShouldCallReaderAndStore() { + MemberBanRequest request = new MemberBanRequest(List.of(1L, 2L, 3L)); + Member mockMember = mock(Member.class); + when(mockMember.getStatus()).thenReturn(MemberStatus.ACTIVE); + when(memberReader.getMemberRegardlessOfStatus(anyLong())).thenReturn(mockMember); + + memberService.banMembers(request); + + verify(memberReader,times(3)).getMemberRegardlessOfStatus(anyLong()); + verify(memberStore, times(3)).banMember(anyLong()); + } + + @DisplayName("banMembers 메서드가 이미 정지된 회원을 정지하려고 할 때 BadRequestException을 던지는지") + @Test + void banMembers_ShouldThrowBadRequestException_WhenMemberIsAlreadyBanned() { + MemberBanRequest request = new MemberBanRequest(List.of(1L, 2L, 3L)); + Member mockMember = mock(Member.class); + when(mockMember.getStatus()).thenReturn(MemberStatus.BANNED); + when(memberReader.getMemberRegardlessOfStatus(anyLong())).thenReturn(mockMember); + + assertThrows(BadRequestException.class, () -> memberService.banMembers(request)); + } + + @DisplayName("unbanMembers 메서드가 MemberReader와 MemberStore를 잘 호출하는지") + @Test + void unbanMembers_ShouldCallReaderAndStore() { + MemberBanRequest request = new MemberBanRequest(List.of(1L, 2L, 3L)); + Member mockMember = mock(Member.class); + when(mockMember.getStatus()).thenReturn(MemberStatus.BANNED); + when(memberReader.getMemberRegardlessOfStatus(anyLong())).thenReturn(mockMember); + + memberService.unbanMembers(request); + + verify(memberReader,times(3)).getMemberRegardlessOfStatus(anyLong()); + verify(memberStore, times(3)).unbanMember(anyLong()); + } + + @DisplayName("unbanMembers 메서드가 이미 활성화된 회원을 활성화하려고 할 때 BadRequestException을 던지는지") + @Test + void unbanMembers_ShouldThrowBadRequestException_WhenMemberIsAlreadyUnbanned() { + MemberBanRequest request = new MemberBanRequest(List.of(1L, 2L, 3L)); + Member mockMember = mock(Member.class); + when(mockMember.getStatus()).thenReturn(MemberStatus.ACTIVE); + when(memberReader.getMemberRegardlessOfStatus(anyLong())).thenReturn(mockMember); + + assertThrows(BadRequestException.class, () -> memberService.unbanMembers(request)); + } + +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/member/infrastructure/MemberReaderImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/member/infrastructure/MemberReaderImplTest.java new file mode 100644 index 000000000..b329c65a8 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/member/infrastructure/MemberReaderImplTest.java @@ -0,0 +1,54 @@ +package kr.co.yigil.member.infrastructure; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.repository.MemberRepository; +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.Pageable; + +@ExtendWith(MockitoExtension.class) +class MemberReaderImplTest { + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private MemberReaderImpl memberReader; + + @DisplayName("getMemberRegardlessOfStatus 메서드가 MemberRepository를 잘 호출하는지") + @Test + void getMemberRegardlessOfStatus_ShouldCallRepository() { + Member member = mock(Member.class); + when(memberRepository.findByIdRegardlessOfStatus(1L)).thenReturn(Optional.of(member)); + + Member memberRegardlessOfStatus = memberReader.getMemberRegardlessOfStatus(1L); + + assertEquals(member, memberRegardlessOfStatus); + verify(memberRepository).findByIdRegardlessOfStatus(1L); + } + + @DisplayName("getMemberPageRegardlessOfStatus 메서드가 MemberRepository를 잘 호출하는지") + @Test + void getMemberPageRegardlessOfStatus_ShouldCallRepository() { + when(memberRepository.findAllMembersRegardlessOfStatus(any())).thenReturn(mock(Page.class)); + Page memberPageRegardlessOfStatus = memberReader.getMemberPageRegardlessOfStatus( + mock(Pageable.class)); + + assertNotNull(memberPageRegardlessOfStatus); + verify(memberRepository).findAllMembersRegardlessOfStatus(any()); + } + + +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/member/infrastructure/MemberStoreImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/member/infrastructure/MemberStoreImplTest.java new file mode 100644 index 000000000..f0bb7004f --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/member/infrastructure/MemberStoreImplTest.java @@ -0,0 +1,38 @@ +package kr.co.yigil.member.infrastructure; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; + +import kr.co.yigil.member.repository.MemberRepository; +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; + +@ExtendWith(MockitoExtension.class) +class MemberStoreImplTest { + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private MemberStoreImpl memberStore; + + @DisplayName("banMember 메서드가 MemberRepository를 잘 호출하는지") + @Test + void banMember_ShouldCallRepository() { + memberStore.banMember(1L); + + verify(memberRepository).banMemberById(1L); + } + + @DisplayName("unbanMember 메서드가 MemberRepository를 잘 호출하는지") + @Test + void unbanMember_ShouldCallRepository() { + memberStore.unbanMember(1L); + + verify(memberRepository).unbanMemberById(1L); + } + +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/member/interfaces/controller/MemberApiControllerTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/member/interfaces/controller/MemberApiControllerTest.java new file mode 100644 index 000000000..6d25cd65a --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/member/interfaces/controller/MemberApiControllerTest.java @@ -0,0 +1,76 @@ +package kr.co.yigil.member.interfaces.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import kr.co.yigil.member.application.MemberFacade; +import kr.co.yigil.member.interfaces.dto.mapper.MemberMapper; +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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith(SpringExtension.class) +@WebMvcTest(MemberApiController.class) +class MemberApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private MemberFacade memberFacade; + + @MockBean + private MemberMapper memberMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @DisplayName("회원 목록 조회가 요청되었을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenGetMembers_thenReturns200AndMembersResponse() throws Exception { + mockMvc.perform(get("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .param("page", "1") + .param("dataCount", "10")) + .andExpect(status().isOk()); + + verify(memberFacade).getMemberPage(any(Pageable.class)); + } + + @DisplayName("회원 정지가 요청되었을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenBanMembers_thenReturns200AndMemberBanResponse() throws Exception { + mockMvc.perform(post("/api/v1/members/ban") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\": [1, 2, 3]}")) + .andExpect(status().isOk()); + + verify(memberFacade).banMembers(any()); + } + + @DisplayName("회원 정지 해제가 요청되었을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenUnbanMembers_thenReturns200AndMemberBanResponse() throws Exception { + mockMvc.perform(post("/api/v1/members/unban") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\": [1, 2, 3]}")) + .andExpect(status().isOk()); + + verify(memberFacade).unbanMembers(any()); + } + + +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/notice/application/NoticeFacadeTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/application/NoticeFacadeTest.java new file mode 100644 index 000000000..ee4a85b49 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/application/NoticeFacadeTest.java @@ -0,0 +1,69 @@ +package kr.co.yigil.notice.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.notice.domain.NoticeCommand.NoticeCreateRequest; +import kr.co.yigil.notice.domain.NoticeCommand.NoticeUpdateRequest; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeDetail; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeListInfo; +import kr.co.yigil.notice.domain.NoticeService; +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; + + +@ExtendWith(MockitoExtension.class) +class NoticeFacadeTest { + + @InjectMocks + private NoticeFacade noticeFacade; + @Mock + private NoticeService noticeService; + + @DisplayName("공지사항 목록 조회가 성공하면 ") + @Test + void whenGetNoticeList_thenShouldReturnNoticeListInfo() { + PageRequest pageRequest = mock(PageRequest.class); + when(noticeService.getNoticeList(pageRequest)).thenReturn(mock(NoticeListInfo.class)); + var result = noticeFacade.getNoticeList(pageRequest); + assertThat(result).isNotNull(); + } + + @DisplayName("공지사항을 생성하면 에러를 발생시키지 않는다") + @Test + void whenCreateNotice_thenShouldNotThrowAnError() { + noticeFacade.createNotice(mock(NoticeCreateRequest.class)); + verify(noticeService).createNotice(any()); + } + + @DisplayName("공지사항을 조회하면 NoticeDetail 객체를 반환한다") + @Test + void whenCreadNotice_thenShouldReturnNoticeDetail() { + + when(noticeService.getNotice(any())).thenReturn(mock(NoticeDetail.class)); + var result = noticeFacade.readNotice(1L); + assertThat(result).isNotNull(); + } + + @DisplayName("공지사항을 수정하면 에러를 발생시키지 않는다") + @Test + void whenUpdateNotice_thenShouldNotThrowAnError() { + noticeFacade.updateNotice(1L, mock(NoticeUpdateRequest.class)); + verify(noticeService).updateNotice(any(), any()); + } + + @DisplayName("공지사항을 삭제하면 에러를 발생시키지 않는다") + @Test + void whenDeleteNotice_thenShouldNotThrowAnError() { + noticeFacade.deleteNotice(1L); + verify(noticeService).deleteNotice(any()); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/notice/domain/NoticeServiceImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/domain/NoticeServiceImplTest.java new file mode 100644 index 000000000..27a206e26 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/domain/NoticeServiceImplTest.java @@ -0,0 +1,120 @@ +package kr.co.yigil.notice.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.admin.domain.Admin; +import kr.co.yigil.admin.domain.admin.AdminReader; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.notice.domain.NoticeCommand.NoticeCreateRequest; +import kr.co.yigil.notice.domain.NoticeCommand.NoticeUpdateRequest; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeListInfo; +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.PageRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +class NoticeServiceImplTest { + + @InjectMocks + private NoticeServiceImpl noticeService; + + @Mock + private NoticeReader noticeReader; + @Mock + private NoticeStore noticeStore; + + @Mock + private AdminReader adminReader; + + + @DisplayName("createNotice 메서드가 잘 동작하는지") + @Test + void whenCreateNotice_thenShouldNotThrowAnError() { + + NoticeCreateRequest noticeCommand = new NoticeCreateRequest("title", "content"); + Admin mockAdmin = mock(Admin.class); + Authentication mockAuthentication = mock(Authentication.class); + SecurityContext mockSecurityContext = mock(SecurityContext.class); + + when(mockSecurityContext.getAuthentication()).thenReturn(mockAuthentication); + SecurityContextHolder.setContext(mockSecurityContext); + when(mockAuthentication.getName()).thenReturn("admin@example.com"); + when(adminReader.getAdminByEmail(any())).thenReturn(mockAdmin); + + noticeService.createNotice(noticeCommand); + + verify(noticeStore).save(any(Notice.class)); + } + + @DisplayName("getNotice 메서드가 잘 동작하는지") + @Test + void whenGetNotice_thenShouldReturnNoticeInfo() { + Long noticeId = 1L; + + Admin admin = new Admin("hllov07@naver.com", "password", "nickname", + List.of("ROLE_ADMIN"), mock(AttachFile.class)); + Notice notice = new Notice(admin, "title", "content"); + + when(noticeReader.getNotice(noticeId)).thenReturn(notice); + + var response = noticeService.getNotice(noticeId); + + assertThat(response).isInstanceOf(NoticeInfo.NoticeDetail.class); + assertThat(response.getTitle()).isEqualTo("title"); + assertThat(response.getContent()).isEqualTo("content"); + assertThat(response.getAuthorNickname()).isEqualTo(admin.getNickname()); + assertThat(response.getCreatedAt()).isEqualTo(notice.getCreatedAt()); + assertThat(response.getContent()).isEqualTo("content"); + } + + @DisplayName("getNoticeList 메서드가 잘 동작하는지") + @Test + void whenGetNoticeList_thenShouldReturnNoticeListInfo() { + var pageRequest = mock(PageRequest.class); + + when(noticeReader.getNoticeList(any(PageRequest.class))).thenReturn(new PageImpl<>( + List.of(new Notice(mock(Admin.class), "title", "content")))); + + var response = noticeService.getNoticeList(pageRequest); + + assertThat(response).isInstanceOf(NoticeListInfo.class); + assertThat(response.getNoticeList()).hasSize(1); + } + + @DisplayName("updateNotice 메서드가 잘 동작하는지") + @Test + void whenUpdateNotice_thenShouldNotThrowAnError() { + Long noticeId = 1L; + NoticeUpdateRequest noticeCommand = new NoticeUpdateRequest("title", "content"); + + when(noticeReader.getNotice(noticeId)).thenReturn( + new Notice(mock(Admin.class), "title", "content")); + + noticeService.updateNotice(noticeId, noticeCommand); + + verify(noticeReader).getNotice(noticeId); + } + + @DisplayName("deleteNotice 메서드가 잘 동작하는지") + @Test + void whenDeleteNotice_thenShouldNotThrowAnError() { + Long noticeId = 1L; + + noticeService.deleteNotice(noticeId); + + verify(noticeStore).delete(noticeId); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/notice/infrastructure/NoticeReaderImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/infrastructure/NoticeReaderImplTest.java new file mode 100644 index 000000000..773ec0dc3 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/infrastructure/NoticeReaderImplTest.java @@ -0,0 +1,54 @@ +package kr.co.yigil.notice.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import kr.co.yigil.notice.domain.Notice; +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.PageRequest; +import org.springframework.data.domain.Slice; + +@ExtendWith(MockitoExtension.class) +class NoticeReaderImplTest { + @InjectMocks + private NoticeReaderImpl noticeReader; + @Mock + private NoticeRepository noticeRepository; + + @DisplayName("getNotice 메서드가 Notice를 잘 반환하는지") + @Test + void whenGetNotice_thenShouldReturnNotice() { + Long noticeId = 1L; + + when(noticeRepository.findById(noticeId)).thenReturn(Optional.of(mock(Notice.class))); + + Notice result = noticeReader.getNotice(noticeId); + assertThat(result).isInstanceOf(Notice.class); + } + + @DisplayName("getNoticeList 메서드가 Slice를 잘 반환하는지") + @Test + void whenGetNoticeList_thenShouldReturnNoticeSlice() { + + Notice notice1 = mock(Notice.class); + Notice notice2 = mock(Notice.class); + List noticeList = List.of(notice1, notice2); + PageRequest pageRequest = PageRequest.of(0, 10); + var noticeSlice = new PageImpl<>(noticeList, PageRequest.of(0, 10), 2L); + when(noticeRepository.findAll(any(PageRequest.class))).thenReturn(noticeSlice); + Slice result = noticeReader.getNoticeList(pageRequest); + + assertThat(result).isInstanceOf(Slice.class); + + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/notice/infrastructure/NoticeStoreImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/infrastructure/NoticeStoreImplTest.java new file mode 100644 index 000000000..c9f907102 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/infrastructure/NoticeStoreImplTest.java @@ -0,0 +1,40 @@ +package kr.co.yigil.notice.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.notice.domain.Notice; +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; + +@ExtendWith(MockitoExtension.class) +class NoticeStoreImplTest { + + @InjectMocks + private NoticeStoreImpl noticeStore; + + @Mock + private NoticeRepository noticeRepository; + + @DisplayName("save 메서드가 잘 동작하는지") + @Test + void save() { + when(noticeRepository.save(any(Notice.class))).thenReturn(mock(Notice.class)); + var result = noticeStore.save(mock(Notice.class)); + assertThat(result).isInstanceOf(Notice.class); + } + + @DisplayName("delete 메서드가 잘 동작하는지") + @Test + void delete() { + noticeStore.delete(1L); + verify(noticeRepository).deleteById(1L); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/notice/interfaces/controller/NoticeApiControllerTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/interfaces/controller/NoticeApiControllerTest.java new file mode 100644 index 000000000..c37981426 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/notice/interfaces/controller/NoticeApiControllerTest.java @@ -0,0 +1,119 @@ +package kr.co.yigil.notice.interfaces.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.yigil.notice.application.NoticeFacade; +import kr.co.yigil.notice.domain.NoticeCommand; +import kr.co.yigil.notice.domain.NoticeInfo; +import kr.co.yigil.notice.domain.NoticeInfo.NoticeListInfo; +import kr.co.yigil.notice.interfaces.dto.NoticeDto; +import kr.co.yigil.notice.interfaces.dto.NoticeDto.NoticeCreateRequest; +import kr.co.yigil.notice.interfaces.dto.mapper.NoticeMapper; +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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith(SpringExtension.class) +@WebMvcTest(NoticeApiController.class) +class NoticeApiControllerTest { + + @MockBean + private NoticeFacade noticeFacade; + @MockBean + private NoticeMapper noticeMapper; + private MockMvc mockMvc; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .build(); + } + + @DisplayName("공지사항 목록 조회가 성공하면 OK 상태를 반환한다.") + @Test + void whenGetNoticeList_thenShouldReturnOKstatus() throws Exception { + + when(noticeFacade.getNoticeList(any(PageRequest.class))).thenReturn( + mock(NoticeListInfo.class)); + when(noticeMapper.toDto(any(NoticeListInfo.class))).thenReturn(mock( + NoticeDto.NoticeListResponse.class)); + + mockMvc.perform(get("/api/v1/notices")) + .andExpect(status().isOk()); + } + + @DisplayName("공지사항이 잘 생성되면 OK 상태를 반환한다.") + @Test + void whenCreateNotice_thenShouldReturnOk() throws Exception { + + NoticeCommand.NoticeCreateRequest command = mock(NoticeCommand.NoticeCreateRequest.class); + + when(noticeMapper.toCommand(any(NoticeCreateRequest.class))).thenReturn(command); + doNothing().when(noticeFacade).createNotice(command); + mockMvc.perform(post("/api/v1/notices") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\": \"title\", \"content\": \"content\"}")) + .andExpect(status().isOk()); + verify(noticeFacade).createNotice(command); + } + + @DisplayName("공지사항이 잘 조회되면 OK 상태를 반환한다.") + @Test + void readNotice() throws Exception { + Long noticeId = 1L; + + NoticeDto.NoticeDetailResponse response = mock(NoticeDto.NoticeDetailResponse.class); + when(noticeFacade.readNotice(anyLong())).thenReturn(mock(NoticeInfo.NoticeDetail.class)); + when(noticeMapper.toDto(any(NoticeInfo.NoticeDetail.class))).thenReturn(response); + + mockMvc.perform(get("/api/v1/notices/{noticeId}", noticeId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @DisplayName("공지사항이 잘 수정되면 OK 상태를 반환한다.") + @Test + void updateNotice() throws Exception { + NoticeDto.NoticeUpdateRequest request = mock(NoticeDto.NoticeUpdateRequest.class); + NoticeCommand.NoticeUpdateRequest command = mock(NoticeCommand.NoticeUpdateRequest.class); + Long noticeId = 1L; + + when(noticeMapper.toCommand(request)).thenReturn(command); + doNothing().when(noticeFacade).updateNotice(noticeId, command); + + mockMvc.perform(post("/api/v1/notices/{noticeId}", noticeId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\": \"title\", \"content\": \"content\"}")) + .andExpect(status().isOk()); + } + + @DisplayName("공지사항이 잘 삭제되면 OK 상태를 반환한다.") + @Test + void deleteNotice() throws Exception { + Long noticeId = 1L; + + doNothing().when(noticeFacade).deleteNotice(noticeId); + + mockMvc.perform(delete("/api/v1/notices/{noticeId}", noticeId)) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/application/CourseFacadeTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/application/CourseFacadeTest.java new file mode 100644 index 000000000..d8263e7d8 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/application/CourseFacadeTest.java @@ -0,0 +1,64 @@ +package kr.co.yigil.travel.course.application; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.admin.domain.admin.AdminService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +import kr.co.yigil.travel.course.domain.CourseInfoDto; +import kr.co.yigil.travel.course.domain.CourseService; +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; + + +@ExtendWith(MockitoExtension.class) +class CourseFacadeTest { + + @Mock + private CourseService courseService; + @Mock + private AdminService adminService; + @Mock + private NotificationService notificationService; + + @InjectMocks + private CourseFacade courseFacade; + + @DisplayName("getCourses 메서드가 CourseService를 잘 호출하는지") + @Test + void getCourses() { + when(courseService.getCourses(any(PageRequest.class))).thenReturn( + mock(CourseInfoDto.CoursesPageInfo.class)); + var response = courseFacade.getCourses(PageRequest.of(0, 10)); + + assertThat(response).isInstanceOf(CourseInfoDto.CoursesPageInfo.class); + } + + @Test + void getCourse() { + when(courseService.getCourse(anyLong())).thenReturn( + mock(CourseInfoDto.CourseDetailInfo.class)); + var response = courseFacade.getCourse(1L); + assertThat(response).isInstanceOf(CourseInfoDto.CourseDetailInfo.class); + } + + @Test + void deleteCourse() { + Long adminId = 1L; + Long memberId = 5L; + when(courseService.deleteCourse(anyLong())).thenReturn(memberId); + when(adminService.getAdminId()).thenReturn(adminId); + courseFacade.deleteCourse(1L); + verify(notificationService).sendNotification(NotificationType.COURSE_DELETED, adminId, memberId); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/domain/CourseServiceImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/domain/CourseServiceImplTest.java new file mode 100644 index 000000000..fea8eeb03 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/domain/CourseServiceImplTest.java @@ -0,0 +1,93 @@ +package kr.co.yigil.travel.course.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.comment.domain.CommentReader; +import kr.co.yigil.favor.domain.FavorReader; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.member.Member; +import kr.co.yigil.travel.course.domain.CourseInfoDto.CoursesPageInfo; +import kr.co.yigil.travel.domain.Course; +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; + +@ExtendWith(MockitoExtension.class) +class CourseServiceImplTest { + + @Mock + private CourseReader courseReader; + @Mock + private CourseStore courseStore; + @Mock + private FavorReader favorReader; + @Mock + private CommentReader commentReader; + @InjectMocks + private CourseServiceImpl courseServiceImpl; + + @DisplayName("getCourses 메서드가 CourseReader를 잘 호출하는지") + @Test + void whenGetCourses_thenShouldReturnCoursesPageInfo() { + // Arrange + PageRequest pageRequest = PageRequest.of(0, 10); + Course course = mock(Course.class); + List courses = List.of(course); + Page pageCourses = new PageImpl<>(courses, pageRequest, courses.size()); + when(courseReader.getCourses(any(PageRequest.class))).thenReturn(pageCourses); + + CourseInfoDto.CourseAdditionalInfo additionalInfo = new CourseInfoDto.CourseAdditionalInfo( + 1, 1); + when(favorReader.getFavorCount(any(Long.class))).thenReturn(additionalInfo.getFavorCount()); + when(commentReader.getCommentCount(any(Long.class))).thenReturn( + additionalInfo.getCommentCount()); + + CoursesPageInfo result = courseServiceImpl.getCourses(pageRequest); + + assertEquals(courses.size(), result.getCourses().getContent().size()); + assertEquals(pageCourses.getTotalElements(), result.getCourses().getTotalElements()); + assertEquals(pageCourses.getPageable(), result.getCourses().getPageable()); + } + + @DisplayName("getCourse 메서드가 CourseReader를 잘 호출하는지") + @Test + void getCourse() { + Long courseId = 1L; + Member member = mock(Member.class); + AttachFile mockAttachFile = new AttachFile(null, "url", "filename", 4L); + Course course = new Course(courseId, member, "title", "content", 3.5, null, false, null, 1, mockAttachFile); + when(courseReader.getCourse(courseId)).thenReturn(course); + + CourseInfoDto.CourseAdditionalInfo additionalInfo = new CourseInfoDto.CourseAdditionalInfo(1, 1); + when(favorReader.getFavorCount(any(Long.class))).thenReturn(additionalInfo.getFavorCount()); + + var result = courseServiceImpl.getCourse(courseId); + assertEquals(course.getId(), result.getCourseId()); + } + + + @DisplayName("deleteCourse 메서드가 CourseStore를 잘 호출하는지") + @Test + void deleteCourse() { + Long courseId = 1L; + Member member = mock(Member.class); + AttachFile mockAttachFile = new AttachFile(null, "url", "filename", 4L); + Course course = new Course(courseId, member, "title", "content", 3.5, null, false, null, 1, mockAttachFile); + when(courseReader.getCourse(courseId)).thenReturn(course); + + var result = courseServiceImpl.deleteCourse(courseId); + assertEquals(member.getId(), result); + verify(courseStore).deleteCourse(course); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/infrastructure/CourseReaderImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/infrastructure/CourseReaderImplTest.java new file mode 100644 index 000000000..291825f82 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/infrastructure/CourseReaderImplTest.java @@ -0,0 +1,51 @@ +package kr.co.yigil.travel.course.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Optional; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.infrastructure.CourseRepository; +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.PageRequest; + +@ExtendWith(MockitoExtension.class) +class CourseReaderImplTest { + + @Mock + private CourseRepository courseRepository; + + @InjectMocks + private CourseReaderImpl courseReader; + + @DisplayName("courseRepository에서 findAll 호출을 잘 하는지") + @Test + void getCourses() { + PageRequest pageRequest = PageRequest.of(0, 10); + when(courseRepository.findAll(pageRequest)).thenReturn(new PageImpl<>(new ArrayList<>())); + var result = courseReader.getCourses(pageRequest); + + assertThat(result) + .isNotNull() + .isInstanceOf(PageImpl.class); + } + + @DisplayName("courseRepository에서 findById 호출을 잘 하는지") + @Test + void getCourse() { + when(courseRepository.findById(1L)).thenReturn(Optional.of(mock(Course.class))); + var result = courseReader.getCourse(1L); + + assertThat(result) + .isNotNull() + .isInstanceOf(Course.class); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/infrastructure/CourseStoreImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/infrastructure/CourseStoreImplTest.java new file mode 100644 index 000000000..e97282756 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/infrastructure/CourseStoreImplTest.java @@ -0,0 +1,34 @@ +package kr.co.yigil.travel.course.infrastructure; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.infrastructure.CourseRepository; +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; + + +@ExtendWith(MockitoExtension.class) +class CourseStoreImplTest { + + @Mock + private CourseRepository courseRepository; + + @InjectMocks + private CourseStoreImpl courseStore; + + @DisplayName("코스 삭제시 repository에 delete 호출 확인") + @Test + void deleteCourse() { + Course mock = mock(Course.class); + + courseStore.deleteCourse(mock); + verify(courseRepository).delete(mock); + + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/interfaces/controller/CourseApiControllerTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/interfaces/controller/CourseApiControllerTest.java new file mode 100644 index 000000000..82e747760 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/course/interfaces/controller/CourseApiControllerTest.java @@ -0,0 +1,73 @@ +package kr.co.yigil.travel.course.interfaces.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import kr.co.yigil.travel.course.application.CourseFacade; +import kr.co.yigil.travel.course.domain.CourseInfoDto; +import kr.co.yigil.travel.course.interfaces.dto.CourseDto.CourseDetailResponse; +import kr.co.yigil.travel.course.interfaces.dto.CourseDto.CoursesResponse; +import kr.co.yigil.travel.course.interfaces.dto.mapper.CourseDtoMapper; +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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith(SpringExtension.class) +@WebMvcTest(CourseApiController.class) +class CourseApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private CourseFacade courseFacade; + @MockBean + private CourseDtoMapper courseDtoMapper; + + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @DisplayName("코스 리스트를 조회하는 테스트") + @Test + void whenGetCourses_thenShouldReturn200AndCoursesResponse() throws Exception{ + when(courseFacade.getCourses(any())).thenReturn(mock(CourseInfoDto.CoursesPageInfo.class)); + when(courseDtoMapper.toPageDtp(any(CourseInfoDto.CoursesPageInfo.class))).thenReturn( + mock(CoursesResponse.class)); + + mockMvc.perform(get("/api/v1/courses")) + .andExpect(status().isOk()); + + } + + @DisplayName("코스 상세 정보를 조회하는 테스트") + @Test + void whenGetCourse_thenShouldReturn200AndCourseDetailResponse() throws Exception { + when(courseFacade.getCourse(any())).thenReturn(mock(CourseInfoDto.CourseDetailInfo.class)); + when(courseDtoMapper.toDetailDto(any(CourseInfoDto.CourseDetailInfo.class))).thenReturn(mock( + CourseDetailResponse.class)); + + + mockMvc.perform(get("/api/v1/courses/1")) + .andExpect(status().isOk()); + } + + @DisplayName("코스를 삭제하는 테스트") + @Test + void whenDeleteCourse_thenShouldReturnCourseDeleteResponse() throws Exception{ + + mockMvc.perform(get("/api/v1/courses/1")) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/application/SpotFacadeTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/application/SpotFacadeTest.java new file mode 100644 index 000000000..2bb859340 --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/application/SpotFacadeTest.java @@ -0,0 +1,68 @@ +package kr.co.yigil.travel.spot.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.admin.domain.admin.AdminService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +import kr.co.yigil.travel.spot.domain.SpotInfoDto; +import kr.co.yigil.travel.spot.domain.SpotService; +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.Pageable; + +@ExtendWith(MockitoExtension.class) +class SpotFacadeTest { + + @Mock + private SpotService spotService; + @Mock + private NotificationService notificationService; + @Mock + private AdminService adminService; + + @InjectMocks + private SpotFacade spotFacade; + + @DisplayName("getSpots 메서드가 SpotService를 잘 호출하는지") + @Test + void whenGetSpots_thenShouldNotthrowAnError() { + + when(spotService.getSpots(any(Pageable.class))).thenReturn( + mock(SpotInfoDto.SpotPageInfo.class)); + + var response = spotFacade.getSpots(mock(Pageable.class)); + assertThat(response).isInstanceOf(SpotInfoDto.SpotPageInfo.class); + + } + + @DisplayName("getSpot 메서드가 SpotService를 잘 호출하는지") + @Test + void whenGetSpot_thenShouldReturnSpotDetailInfo() { + when(spotService.getSpot(any(Long.class))).thenReturn( + mock(SpotInfoDto.SpotDetailInfo.class)); + var response = spotFacade.getSpot(1L); + assertThat(response).isInstanceOf(SpotInfoDto.SpotDetailInfo.class); + } + + @DisplayName("deleteSpot 메서드가 SpotService를 잘 호출하는지") + @Test + void whenDeleteSpot_thenShouldNotThrowAnError() { + Long memberId = 1L; + Long adminId = 3L; + when(spotService.deleteSpot(any(Long.class))).thenReturn(memberId); + when(adminService.getAdminId()).thenReturn(adminId); + spotFacade.deleteSpot(1L); + + verify(notificationService).sendNotification(NotificationType.SPOT_DELETED, adminId, memberId); + + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/domain/SpotServiceImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/domain/SpotServiceImplTest.java new file mode 100644 index 000000000..161cbf2cb --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/domain/SpotServiceImplTest.java @@ -0,0 +1,104 @@ +package kr.co.yigil.travel.spot.domain; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import kr.co.yigil.comment.domain.CommentReader; +import kr.co.yigil.favor.domain.FavorReader; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.AttachFiles; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.spot.domain.SpotInfoDto.SpotPageInfo; +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; + +@ExtendWith(MockitoExtension.class) +class SpotServiceImplTest { + + @Mock + private SpotReader spotReader; + @Mock + private SpotStore spotStore; + @Mock + private FavorReader favorReader; + @Mock + private CommentReader commentReader; + @InjectMocks + private SpotServiceImpl spotServiceImpl; + + @DisplayName("getSpots 메서드가 SpotReader를 잘 호출하고 SpotPageInfo를 반환하는지") + @Test + void whenGetSpots_thenShouldReturnSpotPageInfo() { + + Pageable pageable = PageRequest.of(0, 5); + Spot spot = mock(Spot.class); + List spots = List.of(spot); + Page pageSpots = new PageImpl<>(spots, pageable, spots.size()); + when(spotReader.getSpots(any(Pageable.class))).thenReturn(pageSpots); + + SpotInfoDto.SpotAdditionalInfo additionalInfo = new SpotInfoDto.SpotAdditionalInfo(1, 1); + when(favorReader.getFavorCount(any(Long.class))).thenReturn(additionalInfo.getFavorCount()); + when(commentReader.getCommentCount(any(Long.class))).thenReturn( + additionalInfo.getCommentCount()); + + SpotPageInfo result = spotServiceImpl.getSpots(pageable); + + // Assert + assertEquals(spots.size(), result.getSpots().getContent().size()); + assertEquals(pageSpots.getTotalElements(), result.getSpots().getTotalElements()); + assertEquals(pageSpots.getPageable(), result.getSpots().getPageable()); + } + + @DisplayName("getSpot 메서드가 SpotReader를 잘 호출하고 SpotDetailInfo를 반환하는지") + @Test + void whenGetSpot_thenShouldReturnSpotDetailInfo() { + Long spotId = 1L; + AttachFile mockAttachFile = new AttachFile(null, "url", "filename", 4L); + Place mockPlace = new Place(1L, "name", "address", 4.0, null, mockAttachFile, + mockAttachFile, LocalDateTime.now()); + AttachFiles attachFiles = new AttachFiles(List.of(mockAttachFile, mockAttachFile)); + Spot spot = new Spot(1L, mock(Member.class), null, false, null, null, attachFiles, + mockPlace, 5.0); + when(spotReader.getSpot(spotId)).thenReturn(spot); + SpotInfoDto.SpotAdditionalInfo additionalInfo = new SpotInfoDto.SpotAdditionalInfo(1, 1); + when(favorReader.getFavorCount(any(Long.class))).thenReturn(additionalInfo.getFavorCount()); + when(commentReader.getCommentCount(any(Long.class))).thenReturn( + additionalInfo.getCommentCount()); + + var result = spotServiceImpl.getSpot(spotId); + + assertThat(result).isInstanceOf(SpotInfoDto.SpotDetailInfo.class); + } + + @DisplayName("deleteSpot 메서드가 SpotStore를 잘 호출하고 memberId를 반환하는지") + @Test + void whenGeleteSpot_thenShouldReturnMemberId() { + Spot mockSpot = mock(Spot.class); + Member mockMember = mock(Member.class); + when(spotReader.getSpot(anyLong())).thenReturn(mockSpot); + when(mockSpot.getMember()).thenReturn(mockMember); + when(mockMember.getId()).thenReturn(1L); + + var result = spotServiceImpl.deleteSpot(1L); + + assertEquals(1L, result); + verify(spotStore).deleteSpot(mockSpot); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/infrastructure/SpotReaderImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/infrastructure/SpotReaderImplTest.java new file mode 100644 index 000000000..5db52554e --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/infrastructure/SpotReaderImplTest.java @@ -0,0 +1,53 @@ +package kr.co.yigil.travel.spot.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.infrastructure.SpotRepository; +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; + +@ExtendWith(MockitoExtension.class) +class SpotReaderImplTest { + + @Mock + private SpotRepository spotRepository; + @InjectMocks + private SpotReaderImpl spotReader; + + @DisplayName("getSpots 메서드가 SpotReader를 잘 호출하는지") + @Test + void getSpots() { + Spot spot = mock(Spot.class); + List spots = List.of(spot); + PageRequest pageable = PageRequest.of(0, 5); + Page pageSpots = new PageImpl<>(spots); + when(spotRepository.findAll(pageable)).thenReturn(pageSpots); + + var response = spotReader.getSpots(pageable); + + assertThat(response).isEqualTo(pageSpots); + } + + @DisplayName("getSpot 메서드가 SpotReader를 잘 호출하는지") + @Test + void getSpot() { + Spot spot = mock(Spot.class); + Long spotId = 1L; + when(spotRepository.findById(spotId)).thenReturn(java.util.Optional.of(spot)); + + var response = spotReader.getSpot(spotId); + + assertThat(response).isEqualTo(spot); + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/infrastructure/SpotStoreImplTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/infrastructure/SpotStoreImplTest.java new file mode 100644 index 000000000..c6d28678b --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/infrastructure/SpotStoreImplTest.java @@ -0,0 +1,33 @@ +package kr.co.yigil.travel.spot.infrastructure; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.infrastructure.SpotRepository; +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; + +@ExtendWith(MockitoExtension.class) +class SpotStoreImplTest { + + @Mock + private SpotRepository spotRepository; + @InjectMocks + private SpotStoreImpl spotStore; + + @DisplayName("deleteSpot 메서드가 SpotStore를 잘 호출하는지") + @Test + void deleteSpot() { + Spot mock = mock(Spot.class); + + spotStore.deleteSpot(mock); + + verify(spotRepository).delete(mock); + + } +} \ No newline at end of file diff --git a/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/interfaces/controller/SpotApiControllerTest.java b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/interfaces/controller/SpotApiControllerTest.java new file mode 100644 index 000000000..d2f78b2ac --- /dev/null +++ b/backend/yigil-admin/src/test/java/kr/co/yigil/travel/spot/interfaces/controller/SpotApiControllerTest.java @@ -0,0 +1,73 @@ +package kr.co.yigil.travel.spot.interfaces.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import kr.co.yigil.travel.spot.application.SpotFacade; +import kr.co.yigil.travel.spot.domain.SpotInfoDto; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto.SpotDetailResponse; +import kr.co.yigil.travel.spot.interfaces.dto.SpotDto.SpotsResponse; +import kr.co.yigil.travel.spot.interfaces.dto.mapper.SpotDtoMapper; +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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith(SpringExtension.class) +@WebMvcTest(SpotApiController.class) +class SpotApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private SpotFacade spotFacade; + + @MockBean + private SpotDtoMapper spotDtoMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @DisplayName("회원이 작성한 스팟 리스트를 조회하는 테스트") + @Test + void whenGetSpots_thenShouldReturn200AndSposResponse() throws Exception { + + when(spotFacade.getSpots(any())).thenReturn(mock(SpotInfoDto.SpotPageInfo.class)); + when(spotDtoMapper.of(any(SpotInfoDto.SpotPageInfo.class))).thenReturn( + mock(SpotsResponse.class)); + + mockMvc.perform(get("/api/v1/spots")) + .andExpect(status().isOk()); + + } + + @DisplayName("회원의 스팟 상세 정보를 조회하는 테스트") + @Test + void whenGetSpot_thenShouldReturn200AndSpotDtailResponse() throws Exception { + + when(spotFacade.getSpot(any())).thenReturn(mock(SpotInfoDto.SpotDetailInfo.class)); + when(spotDtoMapper.of(any(SpotInfoDto.SpotDetailInfo.class))).thenReturn(mock( + SpotDetailResponse.class)); + + mockMvc.perform(get("/api/v1/spots/1")) + .andExpect(status().isOk()); + } + + @DisplayName("회원이 작성한 스팟을 삭제하는 테스트") + @Test + void whenDeleteSpot_thenShouldReturn200() throws Exception { + mockMvc.perform(get("/api/v1/spots/1")) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/backend/yigil-api/Dockerfile b/backend/yigil-api/Dockerfile index 9919c048f..4e09cf2d3 100644 --- a/backend/yigil-api/Dockerfile +++ b/backend/yigil-api/Dockerfile @@ -2,8 +2,8 @@ FROM openjdk:21-jdk WORKDIR /app -COPY build/libs/yigil-api-0.0.1-SNAPSHOT.jar app.jar +COPY build/libs/yigil-api-0.0.2-SNAPSHOT.jar app.jar -EXPOSE 8080 +EXPOSE @YIGIL_API_PORT@ -CMD ["java", "-jar", "app.jar"] \ No newline at end of file +CMD ["java", "-jar", "app.jar"] diff --git a/backend/yigil-api/build.gradle b/backend/yigil-api/build.gradle index fda02cf0c..cbcf97332 100644 --- a/backend/yigil-api/build.gradle +++ b/backend/yigil-api/build.gradle @@ -4,21 +4,32 @@ plugins { id 'io.spring.dependency-management' version '1.1.4' id 'org.jetbrains.kotlin.jvm' id 'jacoco' + id "org.asciidoctor.jvm.convert" version "3.3.2" } +ext { + snippetsDir = file('build/generated-snippets') +} + + configurations { + asciidoctorExt + } + bootJar { mainClass = 'kr.co.yigil.BackendApplication' } group = 'kr.co.yigil' -version = '0.0.1-SNAPSHOT' +version = '0.0.2-SNAPSHOT' repositories { mavenCentral() } + dependencies { implementation project(':support:log') + implementation project(':support:domain') implementation 'org.springframework.boot:spring-boot-starter-web-services' compileOnly 'org.projectlombok:lombok' @@ -27,6 +38,9 @@ dependencies { implementation 'com.amazonaws:aws-java-sdk-s3:1.12.528' implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'org.postgresql:postgresql' @@ -40,6 +54,9 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5' testImplementation 'io.projectreactor:reactor-test:3.4.10' + testImplementation 'org.mockito:mockito-inline:3.6.0' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' implementation 'com.github.maricn:logback-slack-appender:1.6.1' @@ -51,6 +68,10 @@ dependencies { implementation 'org.locationtech.jts:jts-core:1.19.0' implementation 'org.locationtech.jts.io:jts-io-common:1.19.0' + + implementation 'javax.xml.bind:jaxb-api:2.3.1' + + implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" } test { @@ -58,10 +79,32 @@ test { useJUnitPlatform() } +tasks.named('test') { + outputs.dir snippetsDir +} + +asciidoctor { + dependsOn test + configurations 'asciidoctorExt' + baseDirFollowsSourceFile() + inputs.dir snippetsDir +} + +asciidoctor.doFirst { + delete file('src/main/resources/static/docs') +} + +tasks.register('copyDocument', Copy) { + dependsOn asciidoctor + from file("build/docs/asciidoc") + into file("src/main/resources/static/docs") +} + jar { enabled = false } bootJar { enabled = true -} \ No newline at end of file + dependsOn copyDocument +} diff --git a/backend/yigil-api/src/docs/asciidoc/bookmark-api.adoc b/backend/yigil-api/src/docs/asciidoc/bookmark-api.adoc new file mode 100644 index 000000000..0bc1095a4 --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/bookmark-api.adoc @@ -0,0 +1,52 @@ +== BOOKMARK API + +=== 북마크 추가 + +==== Request +로그인 필수 : Y + +===== Path Parameters +include::{snippets}/bookmarks/add-bookmark/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/bookmarks/add-bookmark/http-request.adoc[] + +==== Response +include::{snippets}/bookmarks/add-bookmark/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/bookmarks/add-bookmark/http-response.adoc[] + +=== 북마크 삭제 + +==== Request +로그인 필수 : Y + +===== Path Parameters +include::{snippets}/bookmarks/delete-bookmark/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/bookmarks/delete-bookmark/http-request.adoc[] + +==== Response +include::{snippets}/bookmarks/delete-bookmark/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/bookmarks/delete-bookmark/http-response.adoc[] + +=== 북마크 조회 + +==== Request +로그인 필수 : Y + +===== Query Parameters +include::{snippets}/bookmarks/get-bookmarks/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/bookmarks/get-bookmarks/http-request.adoc[] + +==== Response +include::{snippets}/bookmarks/get-bookmarks/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/bookmarks/get-bookmarks/http-response.adoc[] diff --git a/backend/yigil-api/src/docs/asciidoc/comment-api.adoc b/backend/yigil-api/src/docs/asciidoc/comment-api.adoc new file mode 100644 index 000000000..559e4e3ea --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/comment-api.adoc @@ -0,0 +1,75 @@ +== COMMENT API + +=== comment 생성 + +==== Request +로그인 필수 : N +include::{snippets}/comments/comment-create/request-body.adoc[] + +===== HTTP Request 예시 +include::{snippets}/comments/comment-create/http-request.adoc[] + +==== Response +include::{snippets}/comments/comment-create/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/comments/comment-create/http-response.adoc[] + +=== 댓글 리스트 조회 + +==== Request +로그인 필수 : N + +===== Path parameter +include::{snippets}/comments/comment-get-parent/path-parameters.adoc[] +include::{snippets}/comments/comment-get-parent/query-parameters.adoc[] +===== HTTP Request 예시 +include::{snippets}/comments/comment-get-parent/http-request.adoc[] +==== Response +include::{snippets}/comments/comment-get-parent/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/comments/comment-get-parent/http-response.adoc[] + +=== 대댓글 리스트 조회 + +==== Request +로그인 필수 : N + +===== Path parameter +include::{snippets}/comments/comment-get-child/path-parameters.adoc[] +include::{snippets}/comments/comment-get-child/query-parameters.adoc[] +===== HTTP Request 예시 +include::{snippets}/comments/comment-get-child/http-request.adoc[] +==== Response +include::{snippets}/comments/comment-get-child/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/comments/comment-get-child/http-response.adoc[] + +=== comment 삭제 + +==== Request +로그인 필수 : Y + +include::{snippets}/comments/comment-delete/path-parameters.adoc[] +include::{snippets}/comments/comment-delete/request-body.adoc[] +===== HTTP Request 예시 +include::{snippets}/comments/comment-delete/http-request.adoc[] +==== Response +include::{snippets}/comments/comment-delete/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/comments/comment-delete/http-response.adoc[] + +=== comment 수정 + +==== Request +로그인 필수 : Y + +include::{snippets}/comments/comment-update/path-parameters.adoc[] +include::{snippets}/comments/comment-update/request-body.adoc[] +===== HTTP Request 예시 +include::{snippets}/comments/comment-update/http-request.adoc[] + +==== Response +include::{snippets}/comments/comment-update/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/comments/comment-update/http-response.adoc[] \ No newline at end of file diff --git a/backend/yigil-api/src/docs/asciidoc/course-api.adoc b/backend/yigil-api/src/docs/asciidoc/course-api.adoc new file mode 100644 index 000000000..e3e7e7e2b --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/course-api.adoc @@ -0,0 +1,154 @@ +== COURSE API + +=== 장소 내 Course 조회 + +==== Request +include::{snippets}/courses/get-courses-in-place/request-body.adoc[] +로그인 필수 : N + +===== Path parameter +include::{snippets}/courses/get-courses-in-place/path-parameters.adoc[] + +===== Query Parameter +include::{snippets}/courses/get-courses-in-place/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/courses/get-courses-in-place/http-request.adoc[] + +==== Response +include::{snippets}/courses/get-courses-in-place/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/courses/get-courses-in-place/http-response.adoc[] + +=== Course 신규 등록 +링크 : + +==== Request +include::{snippets}/courses/register-course/request-fields.adoc[] +로그인 필수 : Y + +===== Request Part +include::{snippets}/courses/register-course/request-parts.adoc[] + +===== HTTP Request 예시 +include::{snippets}/courses/register-course/http-request.adoc[] + +==== Response +include::{snippets}/courses/register-course/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/courses/register-course/http-response.adoc[] + +=== Course 신규 등록 (이미 등록된 Spot) +링크 : + +==== Request +include::{snippets}/courses/register-course-only/request-fields.adoc[] +로그인 필수 : Y + +===== Request Part +include::{snippets}/courses/register-course-only/request-parts.adoc[] + +===== HTTP Request 예시 +include::{snippets}/courses/register-course-only/http-request.adoc[] + +==== Response +include::{snippets}/courses/register-course-only/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/courses/register-course-only/http-response.adoc[] + +=== Course 상세 정보 조회 + +==== Request +include::{snippets}/courses/retrieve-course/request-body.adoc[] +로그인 필수 : N + +===== Path Parameter +include::{snippets}/courses/retrieve-course/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/courses/retrieve-course/http-request.adoc[] + +==== Response +include::{snippets}/courses/retrieve-course/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/courses/retrieve-course/http-response.adoc[] + +=== Course 업데이트 +링크 : + +==== Request +include::{snippets}/courses/update-course/request-fields.adoc[] +로그인 필수 : Y + +===== Path Parameter +include::{snippets}/courses/update-course/path-parameters.adoc[] + +==== Request Part +include::{snippets}/courses/update-course/request-parts.adoc[] + +===== HTTP Request 예시 +include::{snippets}/courses/update-course/http-request.adoc[] + +==== Response +include::{snippets}/courses/update-course/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/courses/update-course/http-response.adoc[] + +=== Course 삭제 + +==== Request +include::{snippets}/courses/delete-course/request-body.adoc[] +로그인 필수 : Y + +===== Path Parameter +include::{snippets}/courses/delete-course/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/courses/delete-course/http-request.adoc[] + +==== Response +include::{snippets}/courses/delete-course/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/courses/delete-course/http-response.adoc[] + +=== My Course 목록 조회 + +==== Request +include::{snippets}/courses/get-my-course-list/request-body.adoc[] +로그인 필수 : Y + +===== Query Parameter +include::{snippets}/courses/get-my-course-list/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/courses/get-my-course-list/http-request.adoc[] + +==== Response +include::{snippets}/courses/get-my-course-list/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/courses/get-my-course-list/http-response.adoc[] + +=== 장소명으로 코스 검색 + +==== Request +include::{snippets}/courses/search-course-by-place-name/request-body.adoc[] +로그인 필수 : N + +===== Query Parameter +include::{snippets}/courses/search-course-by-place-name/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/courses/search-course-by-place-name/http-request.adoc[] + +==== Response +include::{snippets}/courses/search-course-by-place-name/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/courses/search-course-by-place-name/http-response.adoc[] diff --git a/backend/yigil-api/src/docs/asciidoc/favor-api.adoc b/backend/yigil-api/src/docs/asciidoc/favor-api.adoc new file mode 100644 index 000000000..5dc0e59d9 --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/favor-api.adoc @@ -0,0 +1,37 @@ +== FAVOR API + +=== TRAVEL 좋아요 하기 +==== Request + +===== HTTP Request 예시 +include::{snippets}/likes/add-favor/http-request.adoc[] +===== Path parameters +include::{snippets}/likes/add-favor/path-parameters.adoc[] + +==== Response +include::{snippets}/likes/add-favor/response-fields.adoc[] +==== Response Body +include::{snippets}/likes/add-favor/response-body.adoc[] + +===== HTTP Response 예시 +include::{snippets}/likes/add-favor/http-response.adoc[] +''' + +=== TRAVEL 좋아요 취소하기 + +==== Request + +===== HTTP Request 예시 + +include::{snippets}/likes/delete-favor/http-request.adoc[] + +===== Path parameters + +include::{snippets}/likes/delete-favor/path-parameters.adoc[] + +==== Response + +include::{snippets}/likes/delete-favor/response-fields.adoc[] +==== Response Body + +include::{snippets}/likes/delete-favor/response-body.adoc[] diff --git a/backend/yigil-api/src/docs/asciidoc/follow-api.adoc b/backend/yigil-api/src/docs/asciidoc/follow-api.adoc new file mode 100644 index 000000000..b4f8f9c74 --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/follow-api.adoc @@ -0,0 +1,97 @@ +== FOLLOW API + +=== 팔로우 + +==== Request +로그인 필수 : Y + +===== Path parameter +include::{snippets}/follows/follow/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/follows/follow/http-request.adoc[] + +==== Response +include::{snippets}/follows/follow/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/follows/follow/http-response.adoc[] + +=== 언팔로우 + + +==== Request +로그인 필수 : Y + +===== Path parameter +include::{snippets}/follows/unfollow/path-parameters.adoc[] + + +===== HTTP Request 예시 +include::{snippets}/follows/unfollow/http-request.adoc[] + +==== Response +include::{snippets}/follows/unfollow/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/follows/unfollow/http-response.adoc[] + +=== 내 팔로잉 리스트 + +==== Request +로그인 필수 : Y + +===== Path parameter +include::{snippets}/follows/get-my-following-list/query-parameters.adoc[] +===== HTTP Request 예시 +include::{snippets}/follows/get-my-following-list/http-request.adoc[] +==== Response +include::{snippets}/follows/get-my-following-list/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/follows/get-my-following-list/http-response.adoc[] +''' +=== 내 팔로워 리스트 + +==== Request +로그인 필수 : Y + +===== Path parameter +include::{snippets}/follows/get-my-follower-list/query-parameters.adoc[] +===== HTTP Request 예시 +include::{snippets}/follows/get-my-follower-list/http-request.adoc[] +==== Response +include::{snippets}/follows/get-my-follower-list/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/follows/get-my-follower-list/http-response.adoc[] + + +=== 멤버의 팔로잉 리스트 + +==== Request +로그인 필수 : Y + +// ===== Path parameter +// include::{snippets}/follows/get-member-following-list/request-body.adoc[] +===== HTTP Request 예시 +include::{snippets}/follows/get-member-following-list/http-request.adoc[] +==== Response +include::{snippets}/follows/get-member-following-list/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/follows/get-member-following-list/http-response.adoc[] +''' + +=== 멤버의 팔로워 리스트 + +==== Request +로그인 필수 : Y + +// ===== Path parameter +// include::{snippets}/follows/get-member-follower-list/query-parameters.adoc[] +===== HTTP Request 예시 +include::{snippets}/follows/get-member-follower-list/http-request.adoc[] +==== Response +include::{snippets}/follows/get-member-follower-list/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/follows/get-member-follower-list/http-response.adoc[] + +''' \ No newline at end of file diff --git a/backend/yigil-api/src/docs/asciidoc/index.adoc b/backend/yigil-api/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..05c7bc0af --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/index.adoc @@ -0,0 +1,21 @@ += API Document +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 2 +:sectlinks: + +include::spot-api.adoc[] +include::course-api.adoc[] +include::travel-api.adoc[] +include::place-api.adoc[] +include::region-api.adoc[] +include::bookmark-api.adoc[] +include::favor-api.adoc[] +include::comment-api.adoc[] +include::follow-api.adoc[] +include::login-api.adoc[] +include::member-api.adoc[] +include::notification-api.adoc[] +include::region-api.adoc[] diff --git a/backend/yigil-api/src/docs/asciidoc/login-api.adoc b/backend/yigil-api/src/docs/asciidoc/login-api.adoc new file mode 100644 index 000000000..7f5a3fef1 --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/login-api.adoc @@ -0,0 +1,35 @@ +== LOGIN API + +=== Login + +==== Request +Login required: Y + +===== HTTP Request Example +include::{snippets}/login/login/http-request.adoc[] + +===== Request Headers +include::{snippets}/login/login/request-headers.adoc[] + +===== Request Fields +include::{snippets}/login/login/request-fields.adoc[] + +==== Response +include::{snippets}/login/login/response-fields.adoc[] + +===== HTTP Response Example +include::{snippets}/login/login/http-response.adoc[] + +=== Logout + +==== Request +Login required: Y + +===== HTTP Request Example +include::{snippets}/login/logout/http-request.adoc[] + +==== Response +include::{snippets}/login/logout/response-fields.adoc[] + +===== HTTP Response Example +include::{snippets}/login/logout/http-response.adoc[] \ No newline at end of file diff --git a/backend/yigil-api/src/docs/asciidoc/member-api.adoc b/backend/yigil-api/src/docs/asciidoc/member-api.adoc new file mode 100644 index 000000000..7dc5a27b2 --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/member-api.adoc @@ -0,0 +1,73 @@ +== MEMBER API +[[get-my-info]] +=== 내 정보 조회 +==== Request +로그인 필수: Y + +===== HTTP Request 예시 +include::{snippets}/members/get-my-info/http-request.adoc[] + +==== Response +include::{snippets}/members/get-my-info/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/members/get-my-info/http-response.adoc[] +''' + +[[update-my-info]] +=== 내 정보 수정 + +노션 링크: + +==== Request +로그인 필수: Y + +===== HTTP Request 예시 +include::{snippets}/members/update-my-info/http-request.adoc[] +===== Request Parts +include::{snippets}/members/update-my-info/request-parts.adoc[] + +==== Response +include::{snippets}/members/update-my-info/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/members/update-my-info/http-response.adoc[] +''' + +[[withdraw]] +=== 탈퇴 +==== Request +로그인 필수: Y + +===== HTTP Request 예시 +include::{snippets}/members/withdraw/http-request.adoc[] +==== Response +include::{snippets}/members/withdraw/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/members/withdraw/http-response.adoc[] + +''' +[[get-member-info]] +=== 회원 정보 조회 +로그인 필수: N + +==== Request +===== HTTP Request 예시 +include::{snippets}/members/get-member-info/http-request.adoc[] +==== Response +include::{snippets}/members/get-member-info/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/members/get-member-info/http-response.adoc[] +''' + +=== 닉네임 중복 체크 +로그인 필수: N + +==== Request +===== HTTP Request 예시 +include::{snippets}/members/nickname-duplicate-check/http-request.adoc[] +==== Response +include::{snippets}/members/nickname-duplicate-check/response-fields.adoc[] +===== HTTP Response 예시 +include::{snippets}/members/nickname-duplicate-check/http-response.adoc[] +''' + + diff --git a/backend/yigil-api/src/docs/asciidoc/notification-api.adoc b/backend/yigil-api/src/docs/asciidoc/notification-api.adoc new file mode 100644 index 000000000..a4ae9a7fd --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/notification-api.adoc @@ -0,0 +1,31 @@ +== NOTIFICATION API + +=== 알림 조회 + +==== Request +로그인 필수 : Y + +===== Query Parameters +include::{snippets}/notifications/get-notifications/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/notifications/get-notifications/http-request.adoc[] + +==== Response +include::{snippets}/notifications/get-notifications/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/notifications/get-notifications/http-response.adoc[] + +=== 알림 스트림 + +==== Request +로그인 필수 : Y + +===== HTTP Request 예시 +include::{snippets}/notifications/stream-notification/http-request.adoc[] + +==== Response + +===== HTTP Response 예시 +include::{snippets}/notifications/stream-notification/http-response.adoc[] \ No newline at end of file diff --git a/backend/yigil-api/src/docs/asciidoc/place-api.adoc b/backend/yigil-api/src/docs/asciidoc/place-api.adoc new file mode 100644 index 000000000..db8a75e59 --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/place-api.adoc @@ -0,0 +1,188 @@ +== Place API + +=== Static Image 존재 유무 확인 + +==== Request +include::{snippets}/places/find-static-image/request-body.adoc[] +로그인 필수: Y + +===== Query Parameters +include::{snippets}/places/find-static-image/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/places/find-static-image/http-request.adoc[] + +==== Response +include::{snippets}/places/find-static-image/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/find-static-image/http-response.adoc[] + +=== 인기 장소 목록 조회 + +==== Request +include::{snippets}/places/get-popular-place/request-body.adoc[] +로그인 필수: N + +===== HTTP Request 예시 +include::{snippets}/places/get-popular-place/http-request.adoc[] + +==== Response +include::{snippets}/places/get-popular-place/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/get-popular-place/http-response.adoc[] + +=== 인기 장소 목록 더보기 + +==== Request +include::{snippets}/places/get-popular-place-more/request-body.adoc[] +로그인 필수: N + +===== HTTP Request 예시 +include::{snippets}/places/get-popular-place-more/http-request.adoc[] + +==== Response +include::{snippets}/places/get-popular-place-more/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/get-popular-place-more/http-response.adoc[] + +=== 장소 상세 조회 + +==== Request +include::{snippets}/places/retrieve-place/request-body.adoc[] +로그인 필수: N + +===== Path Parameters +include::{snippets}/places/retrieve-place/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/places/retrieve-place/http-request.adoc[] + +==== Response +include::{snippets}/places/retrieve-place/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/retrieve-place/http-response.adoc[] + +=== 지역별 장소 목록 조회 + +==== Request +include::{snippets}/places/get-region-place/request-body.adoc[] +로그인 필수: N + +===== Path Parameters +include::{snippets}/places/get-region-place/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/places/get-region-place/http-request.adoc[] + +==== Response +include::{snippets}/places/get-region-place/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/get-region-place/http-response.adoc[] + +=== 지역별 장소 목록 더보기 + +==== Request +include::{snippets}/places/get-region-place-more/request-body.adoc[] +로그인 필수: N + +===== Path Parameters +include::{snippets}/places/get-region-place-more/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/places/get-region-place-more/http-request.adoc[] + +==== Response +include::{snippets}/places/get-region-place-more/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/get-region-place-more/http-response.adoc[] + +=== 주변 장소 검색 + +==== Request +include::{snippets}/places/get-near-place/request-body.adoc[] +로그인 필수: N + +===== Query Parameters +include::{snippets}/places/get-near-place/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/places/get-near-place/http-request.adoc[] + +==== Response +include::{snippets}/places/get-near-place/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/get-near-place/http-response.adoc[] + +=== 개인별 추천 장소 조회 + +==== Request +include::{snippets}/places/get-popular-place-by-demographics/request-body.adoc[] +로그인 필수: Y + +===== HTTP Request 예시 +include::{snippets}/places/get-popular-place-by-demographics/http-request.adoc[] + +==== Response +include::{snippets}/places/get-popular-place-by-demographics/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/get-popular-place-by-demographics/http-response.adoc[] + +=== 개인별 추천 장소 더보기 + +==== Request +include::{snippets}/places/get-popular-place-by-demographics-more/request-body.adoc[] +로그인 필수: Y + +===== HTTP Request 예시 +include::{snippets}/places/get-popular-place-by-demographics-more/http-request.adoc[] + +==== Response +include::{snippets}/places/get-popular-place-by-demographics-more/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/get-popular-place-by-demographics-more/http-response.adoc[] + +=== 추천 검색어 조회 + +==== Request +include::{snippets}/places/get-place-keyword/request-body.adoc[] +로그인 필수: N + +==== Query Parameters +include::{snippets}/places/get-place-keyword/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/places/get-place-keyword/http-request.adoc[] + +==== Response +include::{snippets}/places/get-place-keyword/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/get-place-keyword/http-response.adoc[] + +=== 장소 검색 + +==== Request +include::{snippets}/places/search-place/request-body.adoc[] +로그인 필수: N + +==== Query Parameters +include::{snippets}/places/search-place/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/places/search-place/http-request.adoc[] + +==== Response +include::{snippets}/places/search-place/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/places/search-place/http-response.adoc[] + diff --git a/backend/yigil-api/src/docs/asciidoc/region-api.adoc b/backend/yigil-api/src/docs/asciidoc/region-api.adoc new file mode 100644 index 000000000..a0b2b8ac0 --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/region-api.adoc @@ -0,0 +1,31 @@ +== REGION API + +=== 관심 장소 선택을 위한 정보 조회 + +==== Request +include::{snippets}/regions/region-select-form/request-body.adoc[] +로그인 필수 : Y + +===== HTTP Request 예시 +include::{snippets}/regions/region-select-form/http-request.adoc[] + +==== Response +include::{snippets}/regions/region-select-form/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/regions/region-select-form/http-response.adoc[] + +=== 사용자의 관심 지역 목록 조회 + +==== Request +include::{snippets}/regions/my-region/request-body.adoc[] +로그인 필수 : Y + +===== HTTP Request 예시 +include::{snippets}/regions/my-region/http-request.adoc[] + +==== Response +include::{snippets}/regions/my-region/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/regions/my-region/http-response.adoc[] \ No newline at end of file diff --git a/backend/yigil-api/src/docs/asciidoc/spot-api.adoc b/backend/yigil-api/src/docs/asciidoc/spot-api.adoc new file mode 100644 index 000000000..f2219d9ed --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/spot-api.adoc @@ -0,0 +1,138 @@ +== SPOT API + +=== 장소 내 Spot 조회 + +==== Request +include::{snippets}/spots/get-spots-in-place/request-body.adoc[] +로그인 필수 : N + +===== Path parameter +include::{snippets}/spots/get-spots-in-place/path-parameters.adoc[] + +===== Query Parameter +include::{snippets}/spots/get-spots-in-place/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/spots/get-spots-in-place/http-request.adoc[] + +==== Response +include::{snippets}/spots/get-spots-in-place/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/spots/get-spots-in-place/http-response.adoc[] + +=== 장소 내 내가 작성한 Spot 조회 + +==== Request +include::{snippets}/spots/get-my-spot-in-place/request-body.adoc[] +로그인 필수 : Y + +===== Path Parameter +include::{snippets}/spots/get-my-spot-in-place/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/spots/get-my-spot-in-place/http-request.adoc[] + +==== Response +include::{snippets}/spots/get-my-spot-in-place/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/spots/get-my-spot-in-place/http-response.adoc[] + +=== Spot 신규 등록 +노션 링크 : + +==== Request +include::{snippets}/spots/register-spot/request-fields.adoc[] +로그인 필수 : Y + +===== Request Parts +include::{snippets}/spots/register-spot/request-parts.adoc[] + +===== HTTP Request 예시 +include::{snippets}/spots/register-spot/http-request.adoc[] + +==== Response +include::{snippets}/spots/register-spot/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/spots/register-spot/http-response.adoc[] + +=== Spot 상세 정보 조회 + +==== Request +include::{snippets}/spots/retrieve-spot/http-request.adoc[] +로그인 필수 : N + +===== Path Parameter +include::{snippets}/spots/retrieve-spot/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/spots/retrieve-spot/request-body.adoc[] + +==== Response +include::{snippets}/spots/retrieve-spot/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/spots/retrieve-spot/http-response.adoc[] + +=== Spot 수정 +링크 : + +==== Request +include::{snippets}/spots/update-spot/request-fields.adoc[] +로그인 필수 : Y + +===== Path Parameter +include::{snippets}/spots/update-spot/path-parameters.adoc[] + +===== Request Parts +include::{snippets}/spots/update-spot/request-parts.adoc[] + +===== HTTP Request 예시 +include::{snippets}/spots/update-spot/request-body.adoc[] + +==== Response +include::{snippets}/spots/update-spot/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/spots/update-spot/http-response.adoc[] + +=== Spot 삭제 + +==== Request +include::{snippets}/spots/delete-spot/http-request.adoc[] +로그인 필수 : Y + +===== Path Parameter +include::{snippets}/spots/delete-spot/path-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/spots/delete-spot/request-body.adoc[] + +==== Response +include::{snippets}/spots/delete-spot/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/spots/delete-spot/http-response.adoc[] + +=== 내 Spot 목록 조회 + +==== Request +include::{snippets}/spots/get-my-spot-list/request-body.adoc[] +로그인 필수 : Y + +===== Query Parameter +include::{snippets}/spots/get-my-spot-list/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/spots/get-my-spot-list/http-request.adoc[] + +==== Response +include::{snippets}/spots/get-my-spot-list/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/spots/get-my-spot-list/http-response.adoc[] + +''' + diff --git a/backend/yigil-api/src/docs/asciidoc/travel-api.adoc b/backend/yigil-api/src/docs/asciidoc/travel-api.adoc new file mode 100644 index 000000000..a40ab91c5 --- /dev/null +++ b/backend/yigil-api/src/docs/asciidoc/travel-api.adoc @@ -0,0 +1,47 @@ +== Travel API + +=== 게시글 공개 상태로 전환 + +==== Request +include::{snippets}/travels/change-on-public/request-body.adoc[] +로그인 필수 : Y + +===== HTTP Request 예시 +include::{snippets}/travels/change-on-public/http-request.adoc[] + +==== Response +include::{snippets}/travels/change-on-public/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/travels/change-on-public/http-response.adoc[] + +=== 게시글 비공개 상태로 전환 + +==== Request +include::{snippets}/travels/change-on-private/request-body.adoc[] +로그인 필수 : Y + +===== HTTP Request 예시 +include::{snippets}/travels/change-on-private/http-request.adoc[] + +==== Response +include::{snippets}/travels/change-on-private/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/travels/change-on-private/http-response.adoc[] + + +=== 게시글 리스트 공개/비공개 전환 + +==== Request +include::{snippets}/travels/set-travels-visibility/request-body.adoc[] +로그인 필수 : Y + +===== HTTP Request 예시 +include::{snippets}/travels/set-travels-visibility/http-request.adoc[] + +==== Response +include::{snippets}/travels/set-travels-visibility/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/travels/set-travels-visibility/http-response.adoc[] diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/bookmark/interfaces/dto/mapper/BookmarkMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/bookmark/interfaces/dto/mapper/BookmarkMapperImpl.java new file mode 100644 index 000000000..b5d1ac081 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/bookmark/interfaces/dto/mapper/BookmarkMapperImpl.java @@ -0,0 +1,83 @@ +package kr.co.yigil.bookmark.interfaces.dto.mapper; + +import javax.annotation.processing.Generated; +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.bookmark.interfaces.dto.BookmarkInfoDto; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.place.domain.Place; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-04T18:48:06+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class BookmarkMapperImpl implements BookmarkMapper { + + @Override + public BookmarkInfoDto bookmarkToBookmarkInfoDto(Bookmark bookmark) { + if ( bookmark == null ) { + return null; + } + + BookmarkInfoDto bookmarkInfoDto = new BookmarkInfoDto(); + + bookmarkInfoDto.setPlaceId( bookmarkPlaceId( bookmark ) ); + bookmarkInfoDto.setPlaceName( bookmarkPlaceName( bookmark ) ); + bookmarkInfoDto.setPlaceImage( bookmarkPlaceImageFileFileUrl( bookmark ) ); + + bookmarkInfoDto.setRate( (double) 5.0 ); + + return bookmarkInfoDto; + } + + private Long bookmarkPlaceId(Bookmark bookmark) { + if ( bookmark == null ) { + return null; + } + Place place = bookmark.getPlace(); + if ( place == null ) { + return null; + } + Long id = place.getId(); + if ( id == null ) { + return null; + } + return id; + } + + private String bookmarkPlaceName(Bookmark bookmark) { + if ( bookmark == null ) { + return null; + } + Place place = bookmark.getPlace(); + if ( place == null ) { + return null; + } + String name = place.getName(); + if ( name == null ) { + return null; + } + return name; + } + + private String bookmarkPlaceImageFileFileUrl(Bookmark bookmark) { + if ( bookmark == null ) { + return null; + } + Place place = bookmark.getPlace(); + if ( place == null ) { + return null; + } + AttachFile imageFile = place.getImageFile(); + if ( imageFile == null ) { + return null; + } + String fileUrl = imageFile.getFileUrl(); + if ( fileUrl == null ) { + return null; + } + return fileUrl; + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/comment/interfaces/dto/mapper/CommentMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/comment/interfaces/dto/mapper/CommentMapperImpl.java new file mode 100644 index 000000000..bd2c4aaf9 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/comment/interfaces/dto/mapper/CommentMapperImpl.java @@ -0,0 +1,105 @@ +package kr.co.yigil.comment.interfaces.dto.mapper; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; +import kr.co.yigil.comment.domain.CommentCommand; +import kr.co.yigil.comment.domain.CommentInfo; +import kr.co.yigil.comment.interfaces.dto.CommentDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-04T18:48:06+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class CommentMapperImpl implements CommentMapper { + + @Override + public CommentCommand.CommentCreateRequest of(CommentDto.CommentCreateRequest commentCreateRequest) { + if ( commentCreateRequest == null ) { + return null; + } + + CommentCommand.CommentCreateRequest.CommentCreateRequestBuilder commentCreateRequest1 = CommentCommand.CommentCreateRequest.builder(); + + commentCreateRequest1.content( commentCreateRequest.getContent() ); + commentCreateRequest1.parentId( commentCreateRequest.getParentId() ); + + return commentCreateRequest1.build(); + } + + @Override + public CommentCommand.CommentUpdateRequest of(CommentDto.CommentUpdateRequest commentUpdateRequest) { + if ( commentUpdateRequest == null ) { + return null; + } + + CommentCommand.CommentUpdateRequest.CommentUpdateRequestBuilder commentUpdateRequest1 = CommentCommand.CommentUpdateRequest.builder(); + + commentUpdateRequest1.content( commentUpdateRequest.getContent() ); + + return commentUpdateRequest1.build(); + } + + @Override + public CommentDto.CommentsResponse of(CommentInfo.CommentsResponse commentsResponse) { + if ( commentsResponse == null ) { + return null; + } + + CommentDto.CommentsResponse commentsResponse1 = new CommentDto.CommentsResponse(); + + commentsResponse1.setContent( commentsUnitInfoListToCommentsUnitInfoList( commentsResponse.getContent() ) ); + commentsResponse1.setHasNext( commentsResponse.isHasNext() ); + + return commentsResponse1; + } + + @Override + public CommentDto.CommentDeleteResponse of(CommentInfo.DeleteResponse commentDeleteResponse) { + if ( commentDeleteResponse == null ) { + return null; + } + + String message = null; + + message = commentDeleteResponse.message(); + + CommentDto.CommentDeleteResponse commentDeleteResponse1 = new CommentDto.CommentDeleteResponse( message ); + + return commentDeleteResponse1; + } + + protected CommentDto.CommentsUnitInfo commentsUnitInfoToCommentsUnitInfo(CommentInfo.CommentsUnitInfo commentsUnitInfo) { + if ( commentsUnitInfo == null ) { + return null; + } + + CommentDto.CommentsUnitInfo commentsUnitInfo1 = new CommentDto.CommentsUnitInfo(); + + commentsUnitInfo1.setId( commentsUnitInfo.getId() ); + commentsUnitInfo1.setContent( commentsUnitInfo.getContent() ); + commentsUnitInfo1.setMemberId( commentsUnitInfo.getMemberId() ); + commentsUnitInfo1.setMemberNickname( commentsUnitInfo.getMemberNickname() ); + commentsUnitInfo1.setMemberImageUrl( commentsUnitInfo.getMemberImageUrl() ); + commentsUnitInfo1.setChildCount( commentsUnitInfo.getChildCount() ); + commentsUnitInfo1.setCreatedAt( commentsUnitInfo.getCreatedAt() ); + + return commentsUnitInfo1; + } + + protected List commentsUnitInfoListToCommentsUnitInfoList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( CommentInfo.CommentsUnitInfo commentsUnitInfo : list ) { + list1.add( commentsUnitInfoToCommentsUnitInfo( commentsUnitInfo ) ); + } + + return list1; + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/favor/intefaces/dto/mapper/FavorMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/favor/intefaces/dto/mapper/FavorMapperImpl.java new file mode 100644 index 000000000..31b41e6a3 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/favor/intefaces/dto/mapper/FavorMapperImpl.java @@ -0,0 +1,41 @@ +package kr.co.yigil.favor.intefaces.dto.mapper; + +import javax.annotation.processing.Generated; +import kr.co.yigil.favor.domain.FavorInfo; +import kr.co.yigil.favor.intefaces.dto.FavorDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-03T20:17:29+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)" +) +@Component +public class FavorMapperImpl implements FavorMapper { + + @Override + public FavorDto.AddFavorResponse of(FavorInfo.AddFavorResponse response) { + if ( response == null ) { + return null; + } + + FavorDto.AddFavorResponse.AddFavorResponseBuilder addFavorResponse = FavorDto.AddFavorResponse.builder(); + + addFavorResponse.message( response.getMessage() ); + + return addFavorResponse.build(); + } + + @Override + public FavorDto.DeleteFavorResponse of(FavorInfo.DeleteFavorResponse response) { + if ( response == null ) { + return null; + } + + FavorDto.DeleteFavorResponse.DeleteFavorResponseBuilder deleteFavorResponse = FavorDto.DeleteFavorResponse.builder(); + + deleteFavorResponse.message( response.getMessage() ); + + return deleteFavorResponse.build(); + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/favor/interfaces/dto/mapper/FavorMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/favor/interfaces/dto/mapper/FavorMapperImpl.java new file mode 100644 index 000000000..67eb97abf --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/favor/interfaces/dto/mapper/FavorMapperImpl.java @@ -0,0 +1,41 @@ +package kr.co.yigil.favor.interfaces.dto.mapper; + +import javax.annotation.processing.Generated; +import kr.co.yigil.favor.domain.FavorInfo; +import kr.co.yigil.favor.interfaces.dto.FavorDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-04T18:48:06+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class FavorMapperImpl implements FavorMapper { + + @Override + public FavorDto.AddFavorResponse of(FavorInfo.AddFavorResponse response) { + if ( response == null ) { + return null; + } + + FavorDto.AddFavorResponse.AddFavorResponseBuilder addFavorResponse = FavorDto.AddFavorResponse.builder(); + + addFavorResponse.message( response.getMessage() ); + + return addFavorResponse.build(); + } + + @Override + public FavorDto.DeleteFavorResponse of(FavorInfo.DeleteFavorResponse response) { + if ( response == null ) { + return null; + } + + FavorDto.DeleteFavorResponse.DeleteFavorResponseBuilder deleteFavorResponse = FavorDto.DeleteFavorResponse.builder(); + + deleteFavorResponse.message( response.getMessage() ); + + return deleteFavorResponse.build(); + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/follow/interfaces/dto/FollowDtoMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/follow/interfaces/dto/FollowDtoMapperImpl.java new file mode 100644 index 000000000..47ddabe45 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/follow/interfaces/dto/FollowDtoMapperImpl.java @@ -0,0 +1,99 @@ +package kr.co.yigil.follow.interfaces.dto; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; +import kr.co.yigil.follow.domain.FollowInfo; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-05T14:42:07+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class FollowDtoMapperImpl implements FollowDtoMapper { + + @Override + public FollowDto.FollowersResponse of(FollowInfo.FollowersResponse followersResponse) { + if ( followersResponse == null ) { + return null; + } + + FollowDto.FollowersResponse.FollowersResponseBuilder followersResponse1 = FollowDto.FollowersResponse.builder(); + + followersResponse1.content( followerInfoListToFollowerInfoList( followersResponse.getContent() ) ); + followersResponse1.hasNext( followersResponse.isHasNext() ); + + return followersResponse1.build(); + } + + @Override + public FollowDto.FollowingsResponse of(FollowInfo.FollowingsResponse followingsResponse) { + if ( followingsResponse == null ) { + return null; + } + + FollowDto.FollowingsResponse.FollowingsResponseBuilder followingsResponse1 = FollowDto.FollowingsResponse.builder(); + + followingsResponse1.content( followingInfoListToFollowingInfoList( followingsResponse.getContent() ) ); + followingsResponse1.hasNext( followingsResponse.isHasNext() ); + + return followingsResponse1.build(); + } + + protected FollowDto.FollowerInfo followerInfoToFollowerInfo(FollowInfo.FollowerInfo followerInfo) { + if ( followerInfo == null ) { + return null; + } + + FollowDto.FollowerInfo.FollowerInfoBuilder followerInfo1 = FollowDto.FollowerInfo.builder(); + + followerInfo1.memberId( followerInfo.getMemberId() ); + followerInfo1.nickname( followerInfo.getNickname() ); + followerInfo1.profileImageUrl( followerInfo.getProfileImageUrl() ); + followerInfo1.following( followerInfo.isFollowing() ); + + return followerInfo1.build(); + } + + protected List followerInfoListToFollowerInfoList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( FollowInfo.FollowerInfo followerInfo : list ) { + list1.add( followerInfoToFollowerInfo( followerInfo ) ); + } + + return list1; + } + + protected FollowDto.FollowingInfo followingInfoToFollowingInfo(FollowInfo.FollowingInfo followingInfo) { + if ( followingInfo == null ) { + return null; + } + + FollowDto.FollowingInfo.FollowingInfoBuilder followingInfo1 = FollowDto.FollowingInfo.builder(); + + followingInfo1.memberId( followingInfo.getMemberId() ); + followingInfo1.nickname( followingInfo.getNickname() ); + followingInfo1.profileImageUrl( followingInfo.getProfileImageUrl() ); + + return followingInfo1.build(); + } + + protected List followingInfoListToFollowingInfoList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( FollowInfo.FollowingInfo followingInfo : list ) { + list1.add( followingInfoToFollowingInfo( followingInfo ) ); + } + + return list1; + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/login/interfaces/dto/mapper/LoginMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/login/interfaces/dto/mapper/LoginMapperImpl.java new file mode 100644 index 000000000..96315d766 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/login/interfaces/dto/mapper/LoginMapperImpl.java @@ -0,0 +1,32 @@ +package kr.co.yigil.login.interfaces.dto.mapper; + +import javax.annotation.processing.Generated; +import kr.co.yigil.login.domain.LoginCommand; +import kr.co.yigil.login.interfaces.dto.request.LoginRequest; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-04T18:48:06+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class LoginMapperImpl implements LoginMapper { + + @Override + public LoginCommand.LoginRequest toCommandLoginRequest(LoginRequest loginRequest) { + if ( loginRequest == null ) { + return null; + } + + LoginCommand.LoginRequest.LoginRequestBuilder loginRequest1 = LoginCommand.LoginRequest.builder(); + + loginRequest1.id( loginRequest.getId() ); + loginRequest1.nickname( loginRequest.getNickname() ); + loginRequest1.profileImageUrl( loginRequest.getProfileImageUrl() ); + loginRequest1.email( loginRequest.getEmail() ); + loginRequest1.provider( loginRequest.getProvider() ); + + return loginRequest1.build(); + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/member/interfaces/dto/mapper/MemberDtoMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/member/interfaces/dto/mapper/MemberDtoMapperImpl.java new file mode 100644 index 000000000..f937bc398 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/member/interfaces/dto/mapper/MemberDtoMapperImpl.java @@ -0,0 +1,122 @@ +package kr.co.yigil.member.interfaces.dto.mapper; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; +import kr.co.yigil.member.domain.MemberCommand; +import kr.co.yigil.member.domain.MemberInfo; +import kr.co.yigil.member.interfaces.dto.MemberDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-08T23:30:40+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)" +) +@Component +public class MemberDtoMapperImpl implements MemberDtoMapper { + + @Override + public MemberCommand.MemberUpdateRequest of(MemberDto.MemberUpdateRequest request) { + if ( request == null ) { + return null; + } + + MemberCommand.MemberUpdateRequest.MemberUpdateRequestBuilder memberUpdateRequest = MemberCommand.MemberUpdateRequest.builder(); + + List list = request.getFavoriteRegionIds(); + if ( list != null ) { + memberUpdateRequest.favoriteRegionIds( new ArrayList( list ) ); + } + memberUpdateRequest.nickname( request.getNickname() ); + memberUpdateRequest.ages( request.getAges() ); + memberUpdateRequest.gender( request.getGender() ); + memberUpdateRequest.profileImageFile( request.getProfileImageFile() ); + + return memberUpdateRequest.build(); + } + + @Override + public MemberDto.Main of(MemberInfo.Main main) { + if ( main == null ) { + return null; + } + + MemberDto.Main.MainBuilder main1 = MemberDto.Main.builder(); + + main1.memberId( main.getMemberId() ); + main1.email( main.getEmail() ); + main1.nickname( main.getNickname() ); + main1.profileImageUrl( main.getProfileImageUrl() ); + main1.favoriteRegions( favoriteRegionInfoListToFavoriteRegionList( main.getFavoriteRegions() ) ); + main1.followingCount( main.getFollowingCount() ); + main1.followerCount( main.getFollowerCount() ); + + return main1.build(); + } + + @Override + public MemberDto.MemberUpdateResponse of(MemberInfo.MemberUpdateResponse response) { + if ( response == null ) { + return null; + } + + MemberDto.MemberUpdateResponse.MemberUpdateResponseBuilder memberUpdateResponse = MemberDto.MemberUpdateResponse.builder(); + + memberUpdateResponse.message( response.getMessage() ); + + return memberUpdateResponse.build(); + } + + @Override + public MemberDto.MemberDeleteResponse of(MemberInfo.MemberDeleteResponse response) { + if ( response == null ) { + return null; + } + + MemberDto.MemberDeleteResponse.MemberDeleteResponseBuilder memberDeleteResponse = MemberDto.MemberDeleteResponse.builder(); + + memberDeleteResponse.message( response.getMessage() ); + + return memberDeleteResponse.build(); + } + + @Override + public MemberDto.NicknameCheckResponse of(MemberInfo.NicknameCheckInfo response) { + if ( response == null ) { + return null; + } + + MemberDto.NicknameCheckResponse.NicknameCheckResponseBuilder nicknameCheckResponse = MemberDto.NicknameCheckResponse.builder(); + + nicknameCheckResponse.available( response.isAvailable() ); + + return nicknameCheckResponse.build(); + } + + protected MemberDto.FavoriteRegion favoriteRegionInfoToFavoriteRegion(MemberInfo.FavoriteRegionInfo favoriteRegionInfo) { + if ( favoriteRegionInfo == null ) { + return null; + } + + MemberDto.FavoriteRegion.FavoriteRegionBuilder favoriteRegion = MemberDto.FavoriteRegion.builder(); + + favoriteRegion.id( favoriteRegionInfo.getId() ); + favoriteRegion.name( favoriteRegionInfo.getName() ); + + return favoriteRegion.build(); + } + + protected List favoriteRegionInfoListToFavoriteRegionList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( MemberInfo.FavoriteRegionInfo favoriteRegionInfo : list ) { + list1.add( favoriteRegionInfoToFavoriteRegion( favoriteRegionInfo ) ); + } + + return list1; + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/notification/interfaces/dto/mapper/NotificationMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/notification/interfaces/dto/mapper/NotificationMapperImpl.java new file mode 100644 index 000000000..a5d0cded2 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/notification/interfaces/dto/mapper/NotificationMapperImpl.java @@ -0,0 +1,30 @@ +package kr.co.yigil.notification.interfaces.dto.mapper; + +import javax.annotation.processing.Generated; +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.interfaces.dto.NotificationInfoDto; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-04T18:48:06+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class NotificationMapperImpl implements NotificationMapper { + + @Override + public NotificationInfoDto notificationToNotificationInfoDto(Notification notification) { + if ( notification == null ) { + return null; + } + + NotificationInfoDto notificationInfoDto = new NotificationInfoDto(); + + notificationInfoDto.setMessage( notification.getMessage() ); + + notificationInfoDto.setCreateDate( notification.getCreatedAt().toString() ); + + return notificationInfoDto; + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/place/interfaces/dto/mapper/PlaceMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/place/interfaces/dto/mapper/PlaceMapperImpl.java new file mode 100644 index 000000000..e92111cb9 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/place/interfaces/dto/mapper/PlaceMapperImpl.java @@ -0,0 +1,182 @@ +package kr.co.yigil.place.interfaces.dto.mapper; + +import javax.annotation.processing.Generated; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCommand; +import kr.co.yigil.place.domain.PlaceInfo; +import kr.co.yigil.place.interfaces.dto.PlaceCoordinateDto; +import kr.co.yigil.place.interfaces.dto.PlaceDetailInfoDto; +import kr.co.yigil.place.interfaces.dto.PlaceInfoDto; +import kr.co.yigil.place.interfaces.dto.request.NearPlaceRequest; +import kr.co.yigil.place.interfaces.dto.response.PlaceStaticImageResponse; +import org.locationtech.jts.geom.Point; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-07T14:30:33+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class PlaceMapperImpl implements PlaceMapper { + + @Override + public PlaceStaticImageResponse toPlaceStaticImageResponse(PlaceInfo.MapStaticImageInfo info) { + if ( info == null ) { + return null; + } + + String mapStaticImageUrl = null; + boolean exists = false; + + mapStaticImageUrl = info.getImageUrl(); + exists = info.isExists(); + + PlaceStaticImageResponse placeStaticImageResponse = new PlaceStaticImageResponse( exists, mapStaticImageUrl ); + + return placeStaticImageResponse; + } + + @Override + public PlaceInfoDto mainToDto(PlaceInfo.Main main) { + if ( main == null ) { + return null; + } + + Long id = null; + String placeName = null; + String thumbnailImageUrl = null; + boolean isBookmarked = false; + + id = main.getId(); + placeName = main.getName(); + thumbnailImageUrl = main.getThumbnailImageUrl(); + isBookmarked = main.isBookmarked(); + + String reviewCount = String.valueOf(main.getReviewCount()); + String rate = String.format("%.1f", main.getRate()); + + PlaceInfoDto placeInfoDto = new PlaceInfoDto( id, placeName, reviewCount, thumbnailImageUrl, rate, isBookmarked ); + + placeInfoDto.setBookmarked( main.isBookmarked() ); + + return placeInfoDto; + } + + @Override + public PlaceDetailInfoDto toPlaceDetailInfoDto(PlaceInfo.Detail detail) { + if ( detail == null ) { + return null; + } + + Long id = null; + String placeName = null; + String address = null; + String thumbnailImageUrl = null; + String mapStaticImageUrl = null; + boolean isBookmarked = false; + double rate = 0.0d; + int reviewCount = 0; + + id = detail.getId(); + placeName = detail.getName(); + address = detail.getAddress(); + thumbnailImageUrl = detail.getThumbnailImageUrl(); + mapStaticImageUrl = detail.getMapStaticImageUrl(); + isBookmarked = detail.isBookmarked(); + rate = detail.getRate(); + reviewCount = detail.getReviewCount(); + + PlaceDetailInfoDto placeDetailInfoDto = new PlaceDetailInfoDto( id, placeName, address, thumbnailImageUrl, mapStaticImageUrl, isBookmarked, rate, reviewCount ); + + placeDetailInfoDto.setBookmarked( detail.isBookmarked() ); + + return placeDetailInfoDto; + } + + @Override + public PlaceCommand.NearPlaceRequest toNearPlaceCommand(NearPlaceRequest nearPlaceRequest) { + if ( nearPlaceRequest == null ) { + return null; + } + + PlaceCommand.NearPlaceRequest.NearPlaceRequestBuilder nearPlaceRequest1 = PlaceCommand.NearPlaceRequest.builder(); + + nearPlaceRequest1.minCoordinate( nearPlaceRequestToCoordinate( nearPlaceRequest ) ); + nearPlaceRequest1.maxCoordinate( nearPlaceRequestToCoordinate1( nearPlaceRequest ) ); + nearPlaceRequest1.pageNo( nearPlaceRequest.getPage() ); + + return nearPlaceRequest1.build(); + } + + @Override + public PlaceCoordinateDto placeToPlaceCoordinateDto(Place place) { + if ( place == null ) { + return null; + } + + Long id = null; + String placeName = null; + double x = 0.0d; + double y = 0.0d; + + id = place.getId(); + placeName = place.getName(); + x = placeLocationX( place ); + y = placeLocationY( place ); + + PlaceCoordinateDto placeCoordinateDto = new PlaceCoordinateDto( id, x, y, placeName ); + + return placeCoordinateDto; + } + + protected PlaceCommand.Coordinate nearPlaceRequestToCoordinate(NearPlaceRequest nearPlaceRequest) { + if ( nearPlaceRequest == null ) { + return null; + } + + PlaceCommand.Coordinate.CoordinateBuilder coordinate = PlaceCommand.Coordinate.builder(); + + coordinate.x( nearPlaceRequest.getMinX() ); + coordinate.y( nearPlaceRequest.getMinY() ); + + return coordinate.build(); + } + + protected PlaceCommand.Coordinate nearPlaceRequestToCoordinate1(NearPlaceRequest nearPlaceRequest) { + if ( nearPlaceRequest == null ) { + return null; + } + + PlaceCommand.Coordinate.CoordinateBuilder coordinate = PlaceCommand.Coordinate.builder(); + + coordinate.x( nearPlaceRequest.getMaxX() ); + coordinate.y( nearPlaceRequest.getMaxY() ); + + return coordinate.build(); + } + + private double placeLocationX(Place place) { + if ( place == null ) { + return 0.0d; + } + Point location = place.getLocation(); + if ( location == null ) { + return 0.0d; + } + double x = location.getX(); + return x; + } + + private double placeLocationY(Place place) { + if ( place == null ) { + return 0.0d; + } + Point location = place.getLocation(); + if ( location == null ) { + return 0.0d; + } + double y = location.getY(); + return y; + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/region/interfaces/dto/mapper/RegionMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/region/interfaces/dto/mapper/RegionMapperImpl.java new file mode 100644 index 000000000..2051bf99f --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/region/interfaces/dto/mapper/RegionMapperImpl.java @@ -0,0 +1,13 @@ +package kr.co.yigil.region.interfaces.dto.mapper; + +import javax.annotation.processing.Generated; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-04T18:48:06+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class RegionMapperImpl implements RegionMapper { +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/travel/interfaces/dto/mapper/CourseMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/travel/interfaces/dto/mapper/CourseMapperImpl.java new file mode 100644 index 000000000..aeba91a05 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/travel/interfaces/dto/mapper/CourseMapperImpl.java @@ -0,0 +1,238 @@ +package kr.co.yigil.travel.interfaces.dto.mapper; + +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.course.CourseCommand; +import kr.co.yigil.travel.domain.course.CourseInfo; +import kr.co.yigil.travel.domain.spot.SpotCommand; +import kr.co.yigil.travel.interfaces.dto.CourseDetailInfoDto; +import kr.co.yigil.travel.interfaces.dto.CourseInfoDto; +import kr.co.yigil.travel.interfaces.dto.request.CourseRegisterRequest; +import kr.co.yigil.travel.interfaces.dto.request.CourseRegisterWithoutSeriesRequest; +import kr.co.yigil.travel.interfaces.dto.request.CourseUpdateRequest; +import kr.co.yigil.travel.interfaces.dto.request.SpotRegisterRequest; +import kr.co.yigil.travel.interfaces.dto.request.SpotUpdateRequest; +import kr.co.yigil.travel.interfaces.dto.response.MyCoursesResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-04T18:48:06+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class CourseMapperImpl implements CourseMapper { + + @Autowired + private SpotMapper spotMapper; + + @Override + public CourseInfoDto courseToCourseInfoDto(Course course) { + if ( course == null ) { + return null; + } + + CourseInfoDto courseInfoDto = new CourseInfoDto(); + + courseInfoDto.setMapStaticImageFileUrl( course.getMapStaticImageFile().getFileUrl() ); + courseInfoDto.setTitle( course.getTitle() ); + courseInfoDto.setRate( String.valueOf(course.getRate()) ); + courseInfoDto.setSpotCount( String.valueOf(course.getSpots().size()) ); + courseInfoDto.setCreateDate( course.getCreatedAt().toString() ); + courseInfoDto.setOwnerProfileImageUrl( course.getMember().getProfileImageUrl() ); + courseInfoDto.setOwnerNickname( course.getMember().getNickname() ); + + return courseInfoDto; + } + + @Override + public CourseCommand.RegisterCourseRequest toRegisterCourseRequest(CourseRegisterRequest request) { + if ( request == null ) { + return null; + } + + CourseCommand.RegisterCourseRequest.RegisterCourseRequestBuilder registerCourseRequest = CourseCommand.RegisterCourseRequest.builder(); + + registerCourseRequest.title( request.getTitle() ); + registerCourseRequest.description( request.getDescription() ); + registerCourseRequest.rate( request.getRate() ); + registerCourseRequest.isPrivate( request.isPrivate() ); + registerCourseRequest.representativeSpotOrder( request.getRepresentativeSpotOrder() ); + registerCourseRequest.lineStringJson( request.getLineStringJson() ); + registerCourseRequest.mapStaticImageFile( request.getMapStaticImageFile() ); + registerCourseRequest.registerSpotRequests( spotRegisterRequestListToRegisterSpotRequestList( request.getSpotRegisterRequests() ) ); + + return registerCourseRequest.build(); + } + + @Override + public CourseCommand.RegisterCourseRequestWithSpotInfo toRegisterCourseRequest(CourseRegisterWithoutSeriesRequest request) { + if ( request == null ) { + return null; + } + + CourseCommand.RegisterCourseRequestWithSpotInfo.RegisterCourseRequestWithSpotInfoBuilder registerCourseRequestWithSpotInfo = CourseCommand.RegisterCourseRequestWithSpotInfo.builder(); + + registerCourseRequestWithSpotInfo.title( request.getTitle() ); + registerCourseRequestWithSpotInfo.description( request.getDescription() ); + registerCourseRequestWithSpotInfo.rate( request.getRate() ); + registerCourseRequestWithSpotInfo.isPrivate( request.isPrivate() ); + registerCourseRequestWithSpotInfo.representativeSpotOrder( request.getRepresentativeSpotOrder() ); + registerCourseRequestWithSpotInfo.lineStringJson( request.getLineStringJson() ); + registerCourseRequestWithSpotInfo.mapStaticImageFile( request.getMapStaticImageFile() ); + List list = request.getSpotIds(); + if ( list != null ) { + registerCourseRequestWithSpotInfo.spotIds( new ArrayList( list ) ); + } + + return registerCourseRequestWithSpotInfo.build(); + } + + @Override + public CourseCommand.ModifyCourseRequest toModifyCourseRequest(CourseUpdateRequest courseUpdateRequest) { + if ( courseUpdateRequest == null ) { + return null; + } + + CourseCommand.ModifyCourseRequest.ModifyCourseRequestBuilder modifyCourseRequest = CourseCommand.ModifyCourseRequest.builder(); + + modifyCourseRequest.description( courseUpdateRequest.getDescription() ); + modifyCourseRequest.rate( courseUpdateRequest.getRate() ); + List list = courseUpdateRequest.getSpotIdOrder(); + if ( list != null ) { + modifyCourseRequest.spotIdOrder( new ArrayList( list ) ); + } + modifyCourseRequest.modifySpotRequests( spotUpdateRequestListToModifySpotRequestList( courseUpdateRequest.getCourseSpotUpdateRequests() ) ); + + return modifyCourseRequest.build(); + } + + @Override + public CourseDetailInfoDto toCourseDetailInfoDto(CourseInfo.Main courseInfo) { + if ( courseInfo == null ) { + return null; + } + + CourseDetailInfoDto courseDetailInfoDto = new CourseDetailInfoDto(); + + courseDetailInfoDto.setTitle( courseInfo.getTitle() ); + courseDetailInfoDto.setRate( spotMapper.doubleToString( courseInfo.getRate() ) ); + courseDetailInfoDto.setMapStaticImageUrl( courseInfo.getMapStaticImageUrl() ); + courseDetailInfoDto.setDescription( courseInfo.getDescription() ); + courseDetailInfoDto.setSpots( courseSpotInfoListToCourseSpotInfoDtoList( courseInfo.getCourseSpotList() ) ); + + return courseDetailInfoDto; + } + + @Override + public CourseDetailInfoDto.CourseSpotInfoDto toCourseSpotInfoDto(CourseInfo.CourseSpotInfo courseSpotInfo) { + if ( courseSpotInfo == null ) { + return null; + } + + CourseDetailInfoDto.CourseSpotInfoDto courseSpotInfoDto = new CourseDetailInfoDto.CourseSpotInfoDto(); + + courseSpotInfoDto.setOrder( intToString( courseSpotInfo.getOrder() ) ); + courseSpotInfoDto.setPlaceName( courseSpotInfo.getPlaceName() ); + List list = courseSpotInfo.getImageUrlList(); + if ( list != null ) { + courseSpotInfoDto.setImageUrlList( new ArrayList( list ) ); + } + courseSpotInfoDto.setRate( spotMapper.doubleToString( courseSpotInfo.getRate() ) ); + courseSpotInfoDto.setDescription( courseSpotInfo.getDescription() ); + courseSpotInfoDto.setCreateDate( spotMapper.localDateTimeToString( courseSpotInfo.getCreateDate() ) ); + + return courseSpotInfoDto; + } + + @Override + public MyCoursesResponse of(CourseInfo.MyCoursesResponse myCoursesResponse) { + if ( myCoursesResponse == null ) { + return null; + } + + MyCoursesResponse.MyCoursesResponseBuilder myCoursesResponse1 = MyCoursesResponse.builder(); + + myCoursesResponse1.content( courseListInfoListToCourseInfoList( myCoursesResponse.getContent() ) ); + myCoursesResponse1.totalPages( myCoursesResponse.getTotalPages() ); + + return myCoursesResponse1.build(); + } + + @Override + public MyCoursesResponse.CourseInfo of(CourseInfo.CourseListInfo courseListInfo) { + if ( courseListInfo == null ) { + return null; + } + + MyCoursesResponse.CourseInfo.CourseInfoBuilder courseInfo = MyCoursesResponse.CourseInfo.builder(); + + courseInfo.courseId( courseListInfo.getCourseId() ); + courseInfo.title( courseListInfo.getTitle() ); + courseInfo.rate( courseListInfo.getRate() ); + courseInfo.spotCount( courseListInfo.getSpotCount() ); + if ( courseListInfo.getCreatedDate() != null ) { + courseInfo.createdDate( DateTimeFormatter.ISO_LOCAL_DATE_TIME.format( courseListInfo.getCreatedDate() ) ); + } + courseInfo.mapStaticImageUrl( courseListInfo.getMapStaticImageUrl() ); + courseInfo.isPrivate( courseListInfo.getIsPrivate() ); + + return courseInfo.build(); + } + + protected List spotRegisterRequestListToRegisterSpotRequestList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( SpotRegisterRequest spotRegisterRequest : list ) { + list1.add( spotMapper.toRegisterSpotRequest( spotRegisterRequest ) ); + } + + return list1; + } + + protected List spotUpdateRequestListToModifySpotRequestList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( SpotUpdateRequest spotUpdateRequest : list ) { + list1.add( spotMapper.toModifySpotRequest( spotUpdateRequest ) ); + } + + return list1; + } + + protected List courseSpotInfoListToCourseSpotInfoDtoList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( CourseInfo.CourseSpotInfo courseSpotInfo : list ) { + list1.add( toCourseSpotInfoDto( courseSpotInfo ) ); + } + + return list1; + } + + protected List courseListInfoListToCourseInfoList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( CourseInfo.CourseListInfo courseListInfo : list ) { + list1.add( of( courseListInfo ) ); + } + + return list1; + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/travel/interfaces/dto/mapper/SpotMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/travel/interfaces/dto/mapper/SpotMapperImpl.java new file mode 100644 index 000000000..5e95405ea --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/travel/interfaces/dto/mapper/SpotMapperImpl.java @@ -0,0 +1,248 @@ +package kr.co.yigil.travel.interfaces.dto.mapper; + +import java.text.DecimalFormat; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; +import kr.co.yigil.travel.domain.spot.SpotCommand; +import kr.co.yigil.travel.domain.spot.SpotInfo; +import kr.co.yigil.travel.interfaces.dto.SpotDetailInfoDto; +import kr.co.yigil.travel.interfaces.dto.SpotInfoDto; +import kr.co.yigil.travel.interfaces.dto.request.SpotRegisterRequest; +import kr.co.yigil.travel.interfaces.dto.request.SpotUpdateRequest; +import kr.co.yigil.travel.interfaces.dto.response.MySpotInPlaceResponse; +import kr.co.yigil.travel.interfaces.dto.response.MySpotsResponseDto; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-07T21:27:34+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)" +) +@Component +public class SpotMapperImpl implements SpotMapper { + + private final DateTimeFormatter dateTimeFormatter_yyyy_MM_dd_0159776256 = DateTimeFormatter.ofPattern( "yyyy-MM-dd" ); + + @Override + public MySpotInPlaceResponse toMySpotInPlaceResponse(SpotInfo.MySpot mySpot) { + if ( mySpot == null ) { + return null; + } + + MySpotInPlaceResponse mySpotInPlaceResponse = new MySpotInPlaceResponse(); + + mySpotInPlaceResponse.setExists( mySpot.isExists() ); + mySpotInPlaceResponse.setRate( doubleToString( mySpot.getRate() ) ); + List list = mySpot.getImageUrls(); + if ( list != null ) { + mySpotInPlaceResponse.setImageUrls( new ArrayList( list ) ); + } + mySpotInPlaceResponse.setCreateDate( localDateTimeToString( mySpot.getCreateDate() ) ); + mySpotInPlaceResponse.setDescription( mySpot.getDescription() ); + + return mySpotInPlaceResponse; + } + + @Override + public SpotDetailInfoDto toSpotDetailInfoDto(SpotInfo.Main spotInfoMain) { + if ( spotInfoMain == null ) { + return null; + } + + SpotDetailInfoDto spotDetailInfoDto = new SpotDetailInfoDto(); + + spotDetailInfoDto.setPlaceName( spotInfoMain.getPlaceName() ); + spotDetailInfoDto.setRate( doubleToString( spotInfoMain.getRate() ) ); + spotDetailInfoDto.setPlaceAddress( spotInfoMain.getPlaceAddress() ); + spotDetailInfoDto.setMapStaticImageFileUrl( spotInfoMain.getMapStaticImageFileUrl() ); + List list = spotInfoMain.getImageUrls(); + if ( list != null ) { + spotDetailInfoDto.setImageUrls( new ArrayList( list ) ); + } + spotDetailInfoDto.setCreateDate( localDateTimeToString( spotInfoMain.getCreateDate() ) ); + spotDetailInfoDto.setDescription( spotInfoMain.getDescription() ); + + return spotDetailInfoDto; + } + + @Override + public SpotCommand.ModifySpotRequest toModifySpotRequest(SpotUpdateRequest request) { + if ( request == null ) { + return null; + } + + SpotCommand.ModifySpotRequest.ModifySpotRequestBuilder modifySpotRequest = SpotCommand.ModifySpotRequest.builder(); + + modifySpotRequest.id( request.getId() ); + modifySpotRequest.rate( request.getRate() ); + modifySpotRequest.description( request.getDescription() ); + modifySpotRequest.originalImages( originalSpotImageListToOriginalSpotImageList( request.getOriginalSpotImages() ) ); + modifySpotRequest.updatedImages( updateSpotImageListToUpdateSpotImageList( request.getUpdateSpotImages() ) ); + + return modifySpotRequest.build(); + } + + @Override + public SpotCommand.RegisterSpotRequest toRegisterSpotRequest(SpotRegisterRequest request) { + if ( request == null ) { + return null; + } + + SpotCommand.RegisterSpotRequest.RegisterSpotRequestBuilder registerSpotRequest = SpotCommand.RegisterSpotRequest.builder(); + + registerSpotRequest.registerPlaceRequest( spotRegisterRequestToRegisterPlaceRequest( request ) ); + List list = request.getFiles(); + if ( list != null ) { + registerSpotRequest.files( new ArrayList( list ) ); + } + registerSpotRequest.pointJson( request.getPointJson() ); + registerSpotRequest.title( request.getTitle() ); + registerSpotRequest.description( request.getDescription() ); + registerSpotRequest.rate( request.getRate() ); + + return registerSpotRequest.build(); + } + + @Override + public MySpotsResponseDto of(SpotInfo.MySpotsResponse mySpotsResponse) { + if ( mySpotsResponse == null ) { + return null; + } + + MySpotsResponseDto.MySpotsResponseDtoBuilder mySpotsResponseDto = MySpotsResponseDto.builder(); + + mySpotsResponseDto.content( spotListInfoListToSpotInfoList( mySpotsResponse.getContent() ) ); + mySpotsResponseDto.totalPages( mySpotsResponse.getTotalPages() ); + + return mySpotsResponseDto.build(); + } + + @Override + public MySpotsResponseDto.SpotInfo of(SpotInfo.SpotListInfo spotInfo) { + if ( spotInfo == null ) { + return null; + } + + MySpotsResponseDto.SpotInfo.SpotInfoBuilder spotInfo1 = MySpotsResponseDto.SpotInfo.builder(); + + spotInfo1.spotId( spotInfo.getSpotId() ); + spotInfo1.title( spotInfo.getTitle() ); + spotInfo1.rate( spotInfo.getRate() ); + spotInfo1.imageUrl( spotInfo.getImageUrl() ); + if ( spotInfo.getCreatedDate() != null ) { + spotInfo1.createdDate( DateTimeFormatter.ISO_LOCAL_DATE_TIME.format( spotInfo.getCreatedDate() ) ); + } + spotInfo1.isPrivate( spotInfo.getIsPrivate() ); + + return spotInfo1.build(); + } + + @Override + public SpotInfoDto toSpotInfoDto(SpotInfo.Main spotInfoMain) { + if ( spotInfoMain == null ) { + return null; + } + + SpotInfoDto spotInfoDto = new SpotInfoDto(); + + spotInfoDto.setId( spotInfoMain.getId() ); + List list = spotInfoMain.getImageUrls(); + if ( list != null ) { + spotInfoDto.setImageUrlList( new ArrayList( list ) ); + } + spotInfoDto.setDescription( spotInfoMain.getDescription() ); + spotInfoDto.setOwnerProfileImageUrl( spotInfoMain.getOwnerProfileImageUrl() ); + spotInfoDto.setOwnerNickname( spotInfoMain.getOwnerNickname() ); + spotInfoDto.setRate( new DecimalFormat( "#.#" ).format( spotInfoMain.getRate() ) ); + if ( spotInfoMain.getCreateDate() != null ) { + spotInfoDto.setCreateDate( dateTimeFormatter_yyyy_MM_dd_0159776256.format( spotInfoMain.getCreateDate() ) ); + } + spotInfoDto.setLiked( spotInfoMain.isLiked() ); + + return spotInfoDto; + } + + protected SpotCommand.OriginalSpotImage originalSpotImageToOriginalSpotImage(SpotUpdateRequest.OriginalSpotImage originalSpotImage) { + if ( originalSpotImage == null ) { + return null; + } + + SpotCommand.OriginalSpotImage.OriginalSpotImageBuilder originalSpotImage1 = SpotCommand.OriginalSpotImage.builder(); + + originalSpotImage1.imageUrl( originalSpotImage.getImageUrl() ); + originalSpotImage1.index( originalSpotImage.getIndex() ); + + return originalSpotImage1.build(); + } + + protected List originalSpotImageListToOriginalSpotImageList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( SpotUpdateRequest.OriginalSpotImage originalSpotImage : list ) { + list1.add( originalSpotImageToOriginalSpotImage( originalSpotImage ) ); + } + + return list1; + } + + protected SpotCommand.UpdateSpotImage updateSpotImageToUpdateSpotImage(SpotUpdateRequest.UpdateSpotImage updateSpotImage) { + if ( updateSpotImage == null ) { + return null; + } + + SpotCommand.UpdateSpotImage.UpdateSpotImageBuilder updateSpotImage1 = SpotCommand.UpdateSpotImage.builder(); + + updateSpotImage1.imageFile( updateSpotImage.getImageFile() ); + updateSpotImage1.index( updateSpotImage.getIndex() ); + + return updateSpotImage1.build(); + } + + protected List updateSpotImageListToUpdateSpotImageList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( SpotUpdateRequest.UpdateSpotImage updateSpotImage : list ) { + list1.add( updateSpotImageToUpdateSpotImage( updateSpotImage ) ); + } + + return list1; + } + + protected SpotCommand.RegisterPlaceRequest spotRegisterRequestToRegisterPlaceRequest(SpotRegisterRequest spotRegisterRequest) { + if ( spotRegisterRequest == null ) { + return null; + } + + SpotCommand.RegisterPlaceRequest.RegisterPlaceRequestBuilder registerPlaceRequest = SpotCommand.RegisterPlaceRequest.builder(); + + registerPlaceRequest.mapStaticImageFile( spotRegisterRequest.getMapStaticImageFile() ); + registerPlaceRequest.placeImageFile( spotRegisterRequest.getPlaceImageFile() ); + registerPlaceRequest.placeName( spotRegisterRequest.getPlaceName() ); + registerPlaceRequest.placeAddress( spotRegisterRequest.getPlaceAddress() ); + registerPlaceRequest.placePointJson( spotRegisterRequest.getPlacePointJson() ); + + return registerPlaceRequest.build(); + } + + protected List spotListInfoListToSpotInfoList(List list) { + if ( list == null ) { + return null; + } + + List list1 = new ArrayList( list.size() ); + for ( SpotInfo.SpotListInfo spotListInfo : list ) { + list1.add( of( spotListInfo ) ); + } + + return list1; + } +} diff --git a/backend/yigil-api/src/main/generated/kr/co/yigil/travel/interfaces/dto/mapper/TravelMapperImpl.java b/backend/yigil-api/src/main/generated/kr/co/yigil/travel/interfaces/dto/mapper/TravelMapperImpl.java new file mode 100644 index 000000000..22984c302 --- /dev/null +++ b/backend/yigil-api/src/main/generated/kr/co/yigil/travel/interfaces/dto/mapper/TravelMapperImpl.java @@ -0,0 +1,50 @@ +package kr.co.yigil.travel.interfaces.dto.mapper; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; +import kr.co.yigil.travel.domain.TravelCommand; +import kr.co.yigil.travel.interfaces.dto.request.TravelsVisibilityChangeRequest; +import kr.co.yigil.travel.interfaces.dto.response.TravelsVisibilityChangeResponse; +import org.springframework.stereotype.Component; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor", + date = "2024-03-04T18:48:06+0900", + comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.1 (Oracle Corporation)" +) +@Component +public class TravelMapperImpl implements TravelMapper { + + @Override + public TravelCommand.VisibilityChangeRequest of(TravelsVisibilityChangeRequest request) { + if ( request == null ) { + return null; + } + + TravelCommand.VisibilityChangeRequest.VisibilityChangeRequestBuilder visibilityChangeRequest = TravelCommand.VisibilityChangeRequest.builder(); + + List list = request.getTravelIds(); + if ( list != null ) { + visibilityChangeRequest.travelIds( new ArrayList( list ) ); + } + visibilityChangeRequest.isPrivate( request.getIsPrivate() ); + + return visibilityChangeRequest.build(); + } + + @Override + public TravelsVisibilityChangeResponse of(String message) { + if ( message == null ) { + return null; + } + + String message1 = null; + + message1 = message; + + TravelsVisibilityChangeResponse travelsVisibilityChangeResponse = new TravelsVisibilityChangeResponse( message1 ); + + return travelsVisibilityChangeResponse; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/application/BookmarkFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/application/BookmarkFacade.java new file mode 100644 index 000000000..681eef437 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/application/BookmarkFacade.java @@ -0,0 +1,26 @@ +package kr.co.yigil.bookmark.application; + +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.bookmark.domain.BookmarkService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BookmarkFacade { + private final BookmarkService bookmarkService; + + public void addBookmark(Long memberId, Long placeId) { + bookmarkService.addBookmark(memberId, placeId); + } + + public void deleteBookmark(Long memberId, Long placeId) { + bookmarkService.deleteBookmark(memberId, placeId); + } + + public Slice getBookmarkSlice(Long memberId, PageRequest pageRequest) { + return bookmarkService.getBookmarkSlice(memberId, pageRequest); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkCacheStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkCacheStore.java new file mode 100644 index 000000000..ee5cca233 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkCacheStore.java @@ -0,0 +1,5 @@ +package kr.co.yigil.bookmark.domain; + +public interface BookmarkCacheStore { + boolean isBookmarkExist(Long memberId, Long placeId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkReader.java new file mode 100644 index 000000000..ea4e82cdf --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkReader.java @@ -0,0 +1,11 @@ +package kr.co.yigil.bookmark.domain; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface BookmarkReader { + Slice getBookmarkSlice(Long memberId, Pageable pageable); + + boolean isBookmarked(Long memberId, Long placeId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkService.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkService.java new file mode 100644 index 000000000..9bb5593f2 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkService.java @@ -0,0 +1,13 @@ +package kr.co.yigil.bookmark.domain; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; + +public interface BookmarkService { + + void addBookmark(Long memberId, Long placeId); + + void deleteBookmark(Long memberId, Long placeId); + + Slice getBookmarkSlice(Long memberId, PageRequest pageRequest); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkServiceImpl.java new file mode 100644 index 000000000..d920476dd --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkServiceImpl.java @@ -0,0 +1,49 @@ +package kr.co.yigil.bookmark.domain; + +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BookmarkServiceImpl implements BookmarkService{ + private final BookmarkStore bookmarkStore; + private final BookmarkReader bookmarkReader; + private final MemberReader memberReader; + private final PlaceReader placeReader; + @Transactional + @Override + public void addBookmark(Long memberId, Long placeId) { + if (bookmarkReader.isBookmarked(memberId, placeId)) { + throw new BadRequestException(ExceptionCode.ALREADY_BOOKMARKED); + } + Member member = memberReader.getMember(memberId); + Place place = placeReader.getPlace(placeId); + bookmarkStore.store(member, place); + } + + @Transactional + @Override + public void deleteBookmark(Long memberId, Long placeId) { + if (!bookmarkReader.isBookmarked(memberId, placeId)) { + throw new BadRequestException(ExceptionCode.NOT_BOOKMARKED); + } + Member member = memberReader.getMember(memberId); + Place place = placeReader.getPlace(placeId); + bookmarkStore.remove(member, place); + } + + @Transactional(readOnly = true) + @Override + public Slice getBookmarkSlice(Long memberId, PageRequest pageRequest) { + return bookmarkReader.getBookmarkSlice(memberId, pageRequest); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkStore.java new file mode 100644 index 000000000..e4a37d89f --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/domain/BookmarkStore.java @@ -0,0 +1,11 @@ +package kr.co.yigil.bookmark.domain; + +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; + +public interface BookmarkStore { + + void store(Member member, Place place); + + void remove(Member member, Place place); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/infrastructure/BookmarkReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/infrastructure/BookmarkReaderImpl.java new file mode 100644 index 000000000..0b7207850 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/infrastructure/BookmarkReaderImpl.java @@ -0,0 +1,28 @@ +package kr.co.yigil.bookmark.infrastructure; + +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.bookmark.domain.BookmarkReader; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BookmarkReaderImpl implements BookmarkReader { + private final MemberReader memberReader; + private final BookmarkRepository bookmarkRepository; + + @Override + public Slice getBookmarkSlice(Long memberId, Pageable pageable) { + Member member = memberReader.getMember(memberId); + return bookmarkRepository.findAllByMember(member, pageable); + } + + @Override + public boolean isBookmarked(Long memberId, Long placeId) { + return bookmarkRepository.existsByMemberIdAndPlaceId(memberId, placeId); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/infrastructure/BookmarkStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/infrastructure/BookmarkStoreImpl.java new file mode 100644 index 000000000..154a1c0fc --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/infrastructure/BookmarkStoreImpl.java @@ -0,0 +1,24 @@ +package kr.co.yigil.bookmark.infrastructure; + +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.bookmark.domain.BookmarkStore; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BookmarkStoreImpl implements BookmarkStore { + private final BookmarkRepository bookmarkRepository; + @Override + public void store(Member member, Place place) { + bookmarkRepository.save(new Bookmark(member, place)); + } + + @Override + public void remove(Member member, Place place) { + bookmarkRepository.deleteByMemberAndPlace(member, place); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/controller/BookmarkApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/controller/BookmarkApiController.java new file mode 100644 index 000000000..eae810981 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/controller/BookmarkApiController.java @@ -0,0 +1,73 @@ +package kr.co.yigil.bookmark.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.bookmark.application.BookmarkFacade; +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.bookmark.interfaces.dto.mapper.BookmarkMapper; +import kr.co.yigil.bookmark.interfaces.dto.response.AddBookmarkResponse; +import kr.co.yigil.bookmark.interfaces.dto.response.BookmarksResponse; +import kr.co.yigil.bookmark.interfaces.dto.response.DeleteBookmarkResponse; +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class BookmarkApiController { + + private final BookmarkFacade bookmarkFacade; + private final BookmarkMapper bookmarkMapper; + + @PostMapping("/api/v1/add-bookmark/{place_id}") + @MemberOnly + public ResponseEntity addBookmark( + @Auth final Accessor accessor, + @PathVariable("place_id") final Long placeId + ) { + bookmarkFacade.addBookmark(accessor.getMemberId(), placeId); + return ResponseEntity.ok(new AddBookmarkResponse("장소 북마크 추가 성공")); + } + + @PostMapping("/api/v1/delete-bookmark/{place_id}") + @MemberOnly + public ResponseEntity deleteBookmark( + @Auth final Accessor accessor, + @PathVariable("place_id") final Long placeId + ) { + bookmarkFacade.deleteBookmark(accessor.getMemberId(), placeId); + return ResponseEntity.ok(new DeleteBookmarkResponse("장소 북마크 제거 성공")); + } + + @GetMapping("/api/v1/bookmarks") + @MemberOnly + public ResponseEntity getBookmarks( + @Auth final Accessor accessor, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + Slice bookmarkSlice = bookmarkFacade.getBookmarkSlice(accessor.getMemberId(), + pageRequest); + BookmarksResponse response = bookmarkMapper.bookmarkSliceToBookmarksResponse(bookmarkSlice); + return ResponseEntity.ok(response); + } + + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/BookmarkInfoDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/BookmarkInfoDto.java new file mode 100644 index 000000000..3c7322acb --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/BookmarkInfoDto.java @@ -0,0 +1,16 @@ +package kr.co.yigil.bookmark.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BookmarkInfoDto { + private Long placeId; + private String placeName; + private String placeImage; + private Double rate; + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/mapper/BookmarkMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/mapper/BookmarkMapper.java new file mode 100644 index 000000000..abe61a4a3 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/mapper/BookmarkMapper.java @@ -0,0 +1,32 @@ +package kr.co.yigil.bookmark.interfaces.dto.mapper; + +import java.util.List; +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.bookmark.interfaces.dto.BookmarkInfoDto; +import kr.co.yigil.bookmark.interfaces.dto.response.BookmarksResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.data.domain.Slice; + +@Mapper(componentModel = "spring") +public interface BookmarkMapper { + + default BookmarksResponse bookmarkSliceToBookmarksResponse(Slice bookmarkSlice) { + List bookmarkInfoDtoList = bookmarksToBookmarkInfoDtoList( + bookmarkSlice.getContent()); + boolean hasNext = bookmarkSlice.hasNext(); + return new BookmarksResponse(bookmarkInfoDtoList, hasNext); + } + + default List bookmarksToBookmarkInfoDtoList(List bookmarks) { + return bookmarks.stream() + .map(this::bookmarkToBookmarkInfoDto) + .toList(); + } + + @Mapping(target = "placeId", source = "place.id") + @Mapping(target = "placeName", source = "place.name") + @Mapping(target = "placeImage", source = "place.imageFile.fileUrl") + @Mapping(target = "rate", constant = "5.0") // todo add rate + BookmarkInfoDto bookmarkToBookmarkInfoDto(Bookmark bookmark); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/response/AddBookmarkResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/response/AddBookmarkResponse.java new file mode 100644 index 000000000..b4ae8fe92 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/response/AddBookmarkResponse.java @@ -0,0 +1,12 @@ +package kr.co.yigil.bookmark.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AddBookmarkResponse { + private String message; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/response/BookmarksResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/response/BookmarksResponse.java new file mode 100644 index 000000000..47703776c --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/response/BookmarksResponse.java @@ -0,0 +1,15 @@ +package kr.co.yigil.bookmark.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.bookmark.interfaces.dto.BookmarkInfoDto; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BookmarksResponse { + private List bookmarks; + private boolean hasNext; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/response/DeleteBookmarkResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/response/DeleteBookmarkResponse.java new file mode 100644 index 000000000..3f3d9cef9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/bookmark/interfaces/dto/response/DeleteBookmarkResponse.java @@ -0,0 +1,12 @@ +package kr.co.yigil.bookmark.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class DeleteBookmarkResponse { + private String message; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/application/CommentFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/application/CommentFacade.java new file mode 100644 index 000000000..90693d6b5 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/application/CommentFacade.java @@ -0,0 +1,63 @@ +package kr.co.yigil.comment.application; + +import kr.co.yigil.comment.domain.CommentCommand; +import kr.co.yigil.comment.domain.CommentCommand.CommentUpdateRequest; +import kr.co.yigil.comment.domain.CommentInfo; +import kr.co.yigil.comment.domain.CommentService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CommentFacade { + + private final CommentService commentService; + private final NotificationService notificationService; + + @Transactional + public void createComment(Long memberId, Long travelId, + CommentCommand.CommentCreateRequest commentCreateRequest) { + + var createdCommentInfo = commentService.createComment(memberId, travelId, + commentCreateRequest); + + Long notifiedMemberId = createdCommentInfo.getNotificationMemberId(memberId); + if (notifiedMemberId != null) { + notificationService.sendNotification(NotificationType.NEW_COMMENT, memberId, + notifiedMemberId); + } + } + + @Transactional(readOnly = true) + public CommentInfo.CommentsResponse getParentCommentList(Long travelId, Pageable pageable) { + + return commentService.getParentComments(travelId, pageable); + } + + @Transactional(readOnly = true) + public CommentInfo.CommentsResponse getChildCommentList(Long parentId, Pageable pageable) { + return commentService.getChildComments(parentId, pageable); + } + + @Transactional + public void deleteComment(Long memberId, Long commentId) { + commentService.deleteComment(memberId, commentId); + } + + public void updateComment(Long memberId, Long commentId, + CommentUpdateRequest command) { + var updatedCommentInfo = commentService.updateComment(commentId, memberId, command); + + Long notifiedMemberId = updatedCommentInfo.getNotificationMemberId(memberId); + if (notifiedMemberId != null) { + notificationService.sendNotification(NotificationType.UPDATE_COMMENT, memberId, + notifiedMemberId); + } + + } +} + diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/application/CommentRedisIntegrityService.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/application/CommentRedisIntegrityService.java deleted file mode 100644 index 57dca340d..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/application/CommentRedisIntegrityService.java +++ /dev/null @@ -1,34 +0,0 @@ -package kr.co.yigil.comment.application; - -import java.util.Optional; -import kr.co.yigil.comment.domain.CommentCount; -import kr.co.yigil.comment.domain.repository.CommentCountRepository; -import kr.co.yigil.comment.domain.repository.CommentRepository; -import kr.co.yigil.post.domain.Post; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CommentRedisIntegrityService { - private final CommentRepository commentRepository; - private final CommentCountRepository commentCountRepository; - - @Transactional - public CommentCount ensureCommentCount(Post post) { - Long postId = post.getId(); - Optional existingCommentCount = commentCountRepository.findByPostId(postId); - if (existingCommentCount.isPresent()) { - return existingCommentCount.get(); - } else { - CommentCount commentCount = new CommentCount( - postId, -// commentRepository.countByPostId(postId) - commentRepository.countNonDeletedCommentsByPostId(postId) - ); - return commentCountRepository.save(commentCount); - } - } - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/application/CommentService.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/application/CommentService.java deleted file mode 100644 index ffcdfde48..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/application/CommentService.java +++ /dev/null @@ -1,139 +0,0 @@ -package kr.co.yigil.comment.application; - -import static kr.co.yigil.global.exception.ExceptionCode.NOT_FOUND_COMMENT_ID; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import kr.co.yigil.comment.domain.Comment; -import kr.co.yigil.comment.domain.CommentCount; -import kr.co.yigil.comment.domain.repository.CommentCountRepository; -import kr.co.yigil.comment.domain.repository.CommentRepository; -import kr.co.yigil.comment.dto.request.CommentCreateRequest; -import kr.co.yigil.comment.dto.response.CommentCreateResponse; -import kr.co.yigil.comment.dto.response.CommentDeleteResponse; -import kr.co.yigil.comment.dto.response.CommentResponse; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; -import kr.co.yigil.member.application.MemberService; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.notification.application.NotificationService; -import kr.co.yigil.notification.domain.Notification; -import kr.co.yigil.post.application.PostService; -import kr.co.yigil.post.domain.Post; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CommentService { - private final CommentRepository commentRepository; - private final MemberService memberService; - private final NotificationService notificationService; - private final CommentRedisIntegrityService commentRedisIntegrityService; - private final CommentCountRepository commentCountRepository; - private final PostService postService; - - @Transactional - public CommentCreateResponse createComment(Long memberId, Long postId, CommentCreateRequest commentCreateRequest) { - Member member = memberService.findMemberById(memberId); - Post post = postService.findPostById(postId); - - Comment parentComment = null; - if (commentCreateRequest.getParentId() != null && commentCreateRequest.getNotifiedMemberId() != null) { - parentComment = findCommentById(commentCreateRequest.getParentId()); - Member notifiedMember = memberService.findMemberById(commentCreateRequest.getNotifiedMemberId()); - sendCommentNotification(notifiedMember, commentCreateRequest.getContent()); - } - - Comment newComment = new Comment(commentCreateRequest.getContent(), member, post, parentComment); - commentRedisIntegrityService.ensureCommentCount(post); - commentRepository.save(newComment); - incrementCommentCount(postId); - - return new CommentCreateResponse("댓글 생성 성공"); - } - - @Transactional(readOnly = true) - public List getCommentList(Long postId) { - List commentResponses = new ArrayList<>(); - - commentRepository.findTopLevelCommentsByPostId(postId) - .forEach(comment -> { - CommentResponse commentResponse = CommentResponse.from(comment); - commentResponses.add(commentResponse); - commentRepository.findRepliesByPostIdAndParentId(postId, comment.getId()) - .forEach(reply -> { - CommentResponse replyResponse = CommentResponse.from(reply); - commentResponse.addChild(replyResponse); - }); - }) - ; - - commentRedisIntegrityService.ensureCommentCount(postService.findPostById(postId)); - return commentResponses; - } - - @Transactional(readOnly = true) - public List getTopLevelCommentList(Long postId) { - - List commentResponses = new ArrayList<>(); - List comments = commentRepository.findTopLevelCommentsByPostId(postId); - comments.stream() - .map(CommentResponse::from) - .forEach(commentResponses::add); - return commentResponses; - } - - @Transactional(readOnly = true) - public List getReplyCommentList(Long postId, Long parentId) { - List commentResponses = new ArrayList<>(); - List comments = commentRepository.findRepliesByPostIdAndParentId(postId, parentId); - comments.stream() - .map(CommentResponse::from) - .forEach(commentResponses::add); - return commentResponses; - } - - @Transactional - public CommentDeleteResponse deleteComment(Long memberId, Long postId, Long commentId) { - Post post = postService.findPostById(postId); - validateCommentWriter(memberId, commentId); - Comment comment = findCommentById(commentId); - - commentRedisIntegrityService.ensureCommentCount(post); - commentRepository.delete(comment); - decrementCommentCount(postId); - return new CommentDeleteResponse("댓글 삭제 성공"); - } - - private void incrementCommentCount(Long postId) { - commentCountRepository.findByPostId(postId) - .ifPresent(CommentCount::incrementCommentCount); - } - - private void decrementCommentCount(Long postId) { - commentCountRepository.findByPostId(postId) - .ifPresent(CommentCount::decrementCommentCount); - } - - public Comment findCommentById(Long commentId) { - return commentRepository.findById(commentId) - .orElseThrow(() -> new BadRequestException(NOT_FOUND_COMMENT_ID)); - } - - - public void validateCommentWriter(Long memberId, Long commentId) { - if (!commentRepository.existsByMemberIdAndId(memberId, commentId)) { - throw new BadRequestException(ExceptionCode.INVALID_AUTHORITY); - } - } - - private void sendCommentNotification(Member notifiedMember, String content) { - Notification notify = new Notification(notifiedMember, content); - notificationService.sendNotification(notify); - } -} - diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCommand.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCommand.java new file mode 100644 index 000000000..094e76b05 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCommand.java @@ -0,0 +1,46 @@ +package kr.co.yigil.comment.domain; + +import kr.co.yigil.member.Member; +import kr.co.yigil.travel.domain.Travel; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +public class CommentCommand { + + @Getter + @Builder + @ToString + public static class CommentCreateRequest { + private final String content; + private final Long parentId; + + public Comment toEntity(Member member, Travel travel, Comment parentComment) { + return new Comment( + content, + member, + travel, + parentComment + ); + } + } + + @Getter + @Builder + @ToString + public static class CommentUpdateRequest { + + private String content; + + public Comment toEntity(Member member, Travel travel, Comment parentComment) { + return new Comment( + content, + member, + travel, + parentComment + ); + } + } + + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCount.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCount.java deleted file mode 100644 index 80903c7c3..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCount.java +++ /dev/null @@ -1,21 +0,0 @@ -package kr.co.yigil.comment.domain; - -import org.springframework.data.annotation.Id; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.data.redis.core.RedisHash; - -@Getter -@AllArgsConstructor -@RedisHash("commentCount") -public class CommentCount { - - @Id - private Long postId; - - private int commentCount; - - public void incrementCommentCount() { commentCount++; } - - public void decrementCommentCount() { commentCount--;} -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCountCacheReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCountCacheReader.java new file mode 100644 index 000000000..2917c8ea7 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCountCacheReader.java @@ -0,0 +1,6 @@ +package kr.co.yigil.comment.domain; + +public interface CommentCountCacheReader { + + int getCommentCount(Long travelId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCountCacheStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCountCacheStore.java new file mode 100644 index 000000000..5ca440cbc --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentCountCacheStore.java @@ -0,0 +1,9 @@ +package kr.co.yigil.comment.domain; + +public interface CommentCountCacheStore { + + int increaseCommentCount(Long travelId); + + int decreaseCommentCount(Long travelId); + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentInfo.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentInfo.java new file mode 100644 index 000000000..5dfe02870 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentInfo.java @@ -0,0 +1,86 @@ +package kr.co.yigil.comment.domain; + +import java.util.List; +import lombok.Getter; +import org.springframework.data.domain.Slice; + +public class CommentInfo { + + @Getter + public static class CommentNotiInfo { + + private final Long parentCommentMemberId; + private final Long travelMemberId; + + public CommentNotiInfo(Comment comment) { + this.parentCommentMemberId = + comment.getParent() != null ? comment.getParent().getMember().getId() : null; + this.travelMemberId = comment.getTravel().getMember().getId(); + } + + public Long getNotificationMemberId(Long memberId) { + if (parentCommentMemberId != null && !parentCommentMemberId.equals(memberId)) { + return parentCommentMemberId; + } + assert travelMemberId != null; + if (!travelMemberId.equals(memberId)) { + return travelMemberId; + } + return null; + } + } + + + @Getter + public static class CommentsResponse { + + private final List content; + private final boolean hasNext; + + public CommentsResponse(List comments, boolean hasNext) { + this.content = comments; + this.hasNext = hasNext; + } + + public CommentsResponse(Slice comments) { + this.content = comments.getContent().stream().map(CommentsUnitInfo::new).toList(); + this.hasNext = comments.hasNext(); + } + } + + @Getter + public static class CommentsUnitInfo { + + private final Long id; + private final String content; + private final Long memberId; + private final String memberNickname; + private final String memberImageUrl; + private final int childCount; + private final String createdAt; + + public CommentsUnitInfo(Comment comment, int childCount) { + this.id = comment.getId(); + this.content = comment.getContent(); + this.memberId = comment.getMember().getId(); + this.memberNickname = comment.getMember().getNickname(); + this.memberImageUrl = comment.getMember().getProfileImageUrl(); + this.childCount = childCount; + this.createdAt = comment.getCreatedAt().toString(); + } + + public CommentsUnitInfo(Comment comment) { + this.id = comment.getId(); + this.content = comment.getContent(); + this.memberId = comment.getMember().getId(); + this.memberNickname = comment.getMember().getNickname(); + this.memberImageUrl = comment.getMember().getProfileImageUrl(); + this.childCount = 0; + this.createdAt = comment.getCreatedAt().toString(); + } + } + + public record DeleteResponse(String message) { + + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentReader.java new file mode 100644 index 000000000..ba1b32591 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentReader.java @@ -0,0 +1,23 @@ +package kr.co.yigil.comment.domain; + +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface CommentReader { + Optional findComment(Long parentId); + + Comment getCommentWithMemberId(Long commentId, Long memberId); + + Slice getCommentsByTravelId(Long travelId, Pageable pageable); + + Slice getParentCommentsByTravelId(Long travelId, Pageable pageable); + + Slice getChildCommentsByParentId(Long parentId, Pageable pageable); + + int getCommentCount(Long travelId); + + Long getTravelIdByCommentId(Long commentId); + + int getChildrenCommentCount(Long travelId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentService.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentService.java new file mode 100644 index 000000000..a006ff3f9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentService.java @@ -0,0 +1,25 @@ +package kr.co.yigil.comment.domain; + + +import kr.co.yigil.comment.domain.CommentCommand.CommentCreateRequest; +import kr.co.yigil.comment.domain.CommentCommand.CommentUpdateRequest; +import kr.co.yigil.comment.domain.CommentInfo.CommentsResponse; +import kr.co.yigil.comment.domain.CommentInfo.CommentNotiInfo; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +public interface CommentService { + + CommentNotiInfo createComment(Long memberId, Long travelId, + CommentCreateRequest commentCreateRequest); + + void deleteComment(Long memberId, Long commentId); + + CommentsResponse getParentComments(Long travelId, Pageable pageable); + + CommentsResponse getChildComments(Long parentId, Pageable pageable); + + CommentNotiInfo updateComment(Long commentId, Long memberId, CommentUpdateRequest command); +} + diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentServiceImpl.java new file mode 100644 index 000000000..7b7fd78f7 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentServiceImpl.java @@ -0,0 +1,85 @@ +package kr.co.yigil.comment.domain; + + +import java.util.List; +import kr.co.yigil.comment.domain.CommentCommand.CommentUpdateRequest; +import kr.co.yigil.comment.domain.CommentInfo.CommentNotiInfo; +import kr.co.yigil.comment.domain.CommentInfo.CommentsResponse; +import kr.co.yigil.comment.domain.CommentInfo.CommentsUnitInfo; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.travel.domain.Travel; +import kr.co.yigil.travel.domain.TravelReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + + private final CommentReader commentReader; + private final CommentStore commentStore; + private final MemberReader memberReader; + private final TravelReader travelReader; + private final CommentCountCacheStore commentCountCacheStore; + + @Override + @Transactional + public CommentNotiInfo createComment(Long memberId, Long travelId, + CommentCommand.CommentCreateRequest commentCreateRequest) { + + Member member = memberReader.getMember(memberId); + Travel travel = travelReader.getTravel(travelId); + Comment parentComment = commentReader.findComment(commentCreateRequest.getParentId()).orElse(null); + var newComment = commentCreateRequest.toEntity(member, travel, parentComment); + var savedComment = commentStore.save(newComment); + + commentCountCacheStore.increaseCommentCount(travelId); + + return new CommentNotiInfo(savedComment); + } + + @Override + @Transactional + public void deleteComment(Long memberId, Long commentId) { + Comment comment = commentReader.getCommentWithMemberId(commentId, memberId); + commentStore.delete(comment); + + var travelId = commentReader.getTravelIdByCommentId(commentId); + commentCountCacheStore.decreaseCommentCount(travelId); + } + + @Override + @Transactional(readOnly = true) + public CommentsResponse getParentComments(Long travelId, Pageable pageable) { + + Slice comments = commentReader.getParentCommentsByTravelId(travelId, + pageable); + boolean hasNext = comments.hasNext(); + List commentsUnitInfoList = comments.getContent().stream() + .map(comment -> { + int childCount = commentReader.getChildrenCommentCount(comment.getId()); + return new CommentsUnitInfo(comment, childCount); + }).toList(); + return new CommentsResponse(commentsUnitInfoList, hasNext); + } + + @Override + @Transactional(readOnly = true) + public CommentsResponse getChildComments(Long parentId, Pageable pageable) { + Slice comments = commentReader.getChildCommentsByParentId(parentId, pageable); + return new CommentsResponse(comments); + } + + @Override + @Transactional + public CommentNotiInfo updateComment(Long commentId, Long memberId, CommentUpdateRequest command) { + Comment comment = commentReader.getCommentWithMemberId(commentId, memberId); + comment.updateComment(command.getContent()); + + return new CommentNotiInfo(comment); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentStore.java new file mode 100644 index 000000000..9ce7185b9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/CommentStore.java @@ -0,0 +1,8 @@ +package kr.co.yigil.comment.domain; + +public interface CommentStore { + + Comment save(Comment newComment); + + void delete(Comment comment); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/repository/CommentCountRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/repository/CommentCountRepository.java deleted file mode 100644 index c7cdb9c4f..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/repository/CommentCountRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package kr.co.yigil.comment.domain.repository; - -import java.util.Optional; -import kr.co.yigil.comment.domain.CommentCount; -import org.springframework.data.repository.CrudRepository; - -public interface CommentCountRepository extends CrudRepository { - - Optional findByPostId(Long postId); -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/repository/CommentRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/repository/CommentRepository.java deleted file mode 100644 index 1ae2750c9..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/domain/repository/CommentRepository.java +++ /dev/null @@ -1,34 +0,0 @@ -package kr.co.yigil.comment.domain.repository; - -import java.util.List; -import kr.co.yigil.comment.domain.Comment; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface CommentRepository extends JpaRepository { - - @Query("SELECT c FROM Comment c " + - "LEFT JOIN FETCH c.parent " + - "WHERE c.post.id = :postId " + - "ORDER BY c.parent.id ASC NULLS FIRST, c.createdAt ASC") - List findCommentListByPostId(@Param("postId") Long postId); - - List findByPostIdOrderById(Long postId); - - boolean existsByMemberIdAndId(Long memberId, Long commentId); - - @Query("SELECT c FROM Comment c WHERE c.post.id = :postId AND c.parent IS NULL " - + "ORDER BY c.createdAt ASC " - ) - List findTopLevelCommentsByPostId(@Param("postId") Long postId); - - @Query("SELECT c FROM Comment c WHERE c.post.id = :postId AND c.isDeleted = false AND c.parent.id = :parentId " - + "ORDER BY c.createdAt ASC " - ) - List findRepliesByPostIdAndParentId(@Param("postId") Long postId, @Param("parentId") Long parentId); - - @Query("SELECT COUNT(c) FROM Comment c WHERE c.post.id = :postId AND c.isDeleted = false") - int countNonDeletedCommentsByPostId(@Param("postId") Long postId); - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/request/CommentCreateRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/request/CommentCreateRequest.java deleted file mode 100644 index c9cb7a13c..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/request/CommentCreateRequest.java +++ /dev/null @@ -1,26 +0,0 @@ -package kr.co.yigil.comment.dto.request; - -import kr.co.yigil.comment.domain.Comment; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.travel.domain.Travel; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class CommentCreateRequest { - private String content; - private Long parentId; - private Long notifiedMemberId; - - public static Comment toEntity(CommentCreateRequest commentCreateRequest, Member member, Post post) { - return new Comment( - commentCreateRequest.getContent(), - member, - post - ); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/response/CommentCreateResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/response/CommentCreateResponse.java deleted file mode 100644 index 35b21ecff..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/response/CommentCreateResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package kr.co.yigil.comment.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@AllArgsConstructor -@NoArgsConstructor -public class CommentCreateResponse { - private String message; -} - - - diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/response/CommentResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/response/CommentResponse.java deleted file mode 100644 index 079deba08..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/dto/response/CommentResponse.java +++ /dev/null @@ -1,56 +0,0 @@ -package kr.co.yigil.comment.dto.response; - -import java.util.ArrayList; -import java.util.List; -import kr.co.yigil.comment.domain.Comment; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class CommentResponse { - private Long id; - private String content; - - private Long memberId; - private String memberNickname; - private String memberImageUrl; - - private String createdAt; - - private List children = new ArrayList<>(); - - public CommentResponse(Long id, String content, Long memberId, String memberNickname, String memberImageUrl, String createdAt) { - this.id = id; - this.content = content; - this.memberId = memberId; - this.memberNickname = memberNickname; - this.memberImageUrl = memberImageUrl; - this.createdAt = createdAt; - } - - public void addChild(CommentResponse commentResponse){ - this.children.add(commentResponse); - } - - public static CommentResponse from(Comment comment) { - String content; - - if(comment.isDeleted()){ - content = "삭제된 댓글입니다."; - }else{ - content = comment.getContent(); - } - return new CommentResponse( - comment.getId(), - content, - comment.getMember().getId(), - comment.getMember().getNickname(), - comment.getMember().getProfileImageUrl(), - comment.getCreatedAt().toString() - ); - } - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentCountCacheReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentCountCacheReaderImpl.java new file mode 100644 index 000000000..a624f4497 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentCountCacheReaderImpl.java @@ -0,0 +1,18 @@ +package kr.co.yigil.comment.infrastructure; + +import kr.co.yigil.comment.domain.CommentCountCacheReader; +import kr.co.yigil.comment.domain.CommentReader; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CommentCountCacheReaderImpl implements CommentCountCacheReader { + private final CommentReader commentReader; + @Override + @Cacheable(value = "commentCount") + public int getCommentCount(Long travelId) { + return commentReader.getCommentCount(travelId); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentCountCacheStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentCountCacheStoreImpl.java new file mode 100644 index 000000000..f51016d42 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentCountCacheStoreImpl.java @@ -0,0 +1,29 @@ +package kr.co.yigil.comment.infrastructure; + +import kr.co.yigil.comment.domain.CommentCountCacheReader; +import kr.co.yigil.comment.domain.CommentCountCacheStore; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CachePut; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class CommentCountCacheStoreImpl implements CommentCountCacheStore { + + private final CommentCountCacheReader commentCountCacheReader; + + @Override + @CachePut(value = "commentCount") + public int increaseCommentCount(Long travelId) { + int commentCount = commentCountCacheReader.getCommentCount(travelId); + return ++commentCount; + } + + @Override + @CachePut(value = "commentCount") + public int decreaseCommentCount(Long travelId) { + int commentCount = commentCountCacheReader.getCommentCount(travelId); + return --commentCount; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentReaderImpl.java new file mode 100644 index 000000000..0dfee0672 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentReaderImpl.java @@ -0,0 +1,61 @@ +package kr.co.yigil.comment.infrastructure; + + +import java.util.Optional; +import kr.co.yigil.comment.domain.Comment; +import kr.co.yigil.comment.domain.CommentReader; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CommentReaderImpl implements CommentReader { + private final CommentRepository commentRepository; + + @Override + public Optional findComment(Long commentId) { + if(commentId == null) return Optional.empty(); + return commentRepository.findById(commentId); + } + + @Override + public Comment getCommentWithMemberId(Long commentId, Long memberId) { + return commentRepository.findByIdAndMemberId(commentId, memberId).orElseThrow( + () -> new BadRequestException(ExceptionCode.INVALID_AUTHORITY)); + } + + @Override + public Slice getCommentsByTravelId(Long travelId, Pageable pageable) { + return commentRepository.findAllByTravelIdAndParentIsNull(travelId, pageable); + } + + @Override + public Slice getParentCommentsByTravelId(Long travelId, Pageable pageable) { + return commentRepository.findAllByTravelIdAndParentIsNull(travelId, pageable); + } + + @Override + public Slice getChildCommentsByParentId(Long parentId, Pageable pageable) { + return commentRepository.findChildCommentsByParentId(parentId, pageable); + } + + @Override + public int getCommentCount(Long travelId) { + return commentRepository.countAllByTravelIdAndIsDeletedFalse(travelId); + } + + @Override + public Long getTravelIdByCommentId(Long commentId) { + return commentRepository.findTravelIdByCommentId(commentId).orElseThrow( + () -> new BadRequestException(ExceptionCode.NOT_FOUND_TRAVEL_ID)); + } + + @Override + public int getChildrenCommentCount(Long parentId) { + return commentRepository.countByParentId(parentId); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentStoreImpl.java new file mode 100644 index 000000000..1d1126f8b --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/infrastructure/CommentStoreImpl.java @@ -0,0 +1,27 @@ +package kr.co.yigil.comment.infrastructure; + +import kr.co.yigil.comment.domain.Comment; +import kr.co.yigil.comment.domain.CommentStore; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class CommentStoreImpl implements CommentStore { + + private final CommentRepository commentRepository; + + @Override + public Comment save(Comment newComment) { + return commentRepository.save(newComment); + } + + @Override + public void delete(Comment comment) { + if(comment.isDeleted()) throw new BadRequestException(ExceptionCode.ALREADY_REMOVED_COMMENT); + commentRepository.delete(comment); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/interfaces/controller/CommentApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/interfaces/controller/CommentApiController.java new file mode 100644 index 000000000..f5c8fb91b --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/interfaces/controller/CommentApiController.java @@ -0,0 +1,99 @@ +package kr.co.yigil.comment.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.comment.application.CommentFacade; +import kr.co.yigil.comment.domain.CommentInfo; +import kr.co.yigil.comment.interfaces.dto.CommentDto; +import kr.co.yigil.comment.interfaces.dto.CommentDto.CommentCreateRequest; +import kr.co.yigil.comment.interfaces.dto.CommentDto.CommentCreateResponse; +import kr.co.yigil.comment.interfaces.dto.mapper.CommentMapper; +import kr.co.yigil.global.SortBy; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/comments") +@RequiredArgsConstructor +public class CommentApiController { + + private final CommentFacade commentFacade; + private final CommentMapper commentMapper; + + @PostMapping("/travels/{travel_id}") + @MemberOnly + public ResponseEntity createComment( + @RequestBody CommentCreateRequest commentCreateRequest, + @Auth final Accessor accessor, + @PathVariable("travel_id") Long travelId + ) { + + var command = commentMapper.of(commentCreateRequest); + commentFacade.createComment(accessor.getMemberId(), travelId, command); + return ResponseEntity.ok().body(new CommentCreateResponse("댓글 생성 성공")); + } + + @GetMapping("/travels/{travel_id}") + public ResponseEntity getParentCommentList( + @PathVariable("travel_id") Long travelId, + @PageableDefault(size = 5, page = 1 ) Pageable pageable + ) { + + var pageRequest = PageRequest.of(pageable.getPageNumber()-1, pageable.getPageSize() + , Sort.by(Sort.Direction.ASC, SortBy.CREATED_AT.getValue())); + CommentInfo.CommentsResponse parentCommentList = commentFacade.getParentCommentList( + travelId, + pageRequest); + var response = commentMapper.of(parentCommentList); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/parents/{comment_id}") + public ResponseEntity getChildCommentList( + @PathVariable("comment_id") Long commentId, + @PageableDefault(size = 5, page = 1 ) Pageable pageable + ) { + + var pageRequest = PageRequest.of(pageable.getPageNumber()-1, pageable.getPageSize() + , Sort.by(Direction.ASC, SortBy.CREATED_AT.getValue())); + CommentInfo.CommentsResponse childCommentList = commentFacade.getChildCommentList(commentId, + pageRequest); + var response = commentMapper.of(childCommentList); + return ResponseEntity.ok().body(response); + } + + @PostMapping("/{comment_id}") + @MemberOnly + public ResponseEntity updateComment( + @PathVariable("comment_id") Long commentId, + @RequestBody CommentDto.CommentUpdateRequest commentUpdateRequest, + @Auth final Accessor accessor + ) { + var command = commentMapper.of(commentUpdateRequest); + commentFacade.updateComment(accessor.getMemberId(), commentId, command); + return ResponseEntity.ok().body(new CommentDto.CommentUpdateResponse("댓글 수정 성공")); + } + + @DeleteMapping("/{comment_id}") + @MemberOnly + public ResponseEntity deleteComment( + @PathVariable("comment_id") Long commentId, + @Auth final Accessor accessor + ) { + commentFacade.deleteComment(accessor.getMemberId(), commentId); + return ResponseEntity.ok().body(new CommentDto.CommentDeleteResponse("댓글 삭제 성공")); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/interfaces/dto/CommentDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/interfaces/dto/CommentDto.java new file mode 100644 index 000000000..c3739865f --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/interfaces/dto/CommentDto.java @@ -0,0 +1,82 @@ +package kr.co.yigil.comment.interfaces.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +public class CommentDto { + + //request + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class CommentCreateRequest { + + private String content; + private Long parentId; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class CommentUpdateRequest { + + private String content; + } + + //response + + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class CommentCreateResponse { + + private String message; + } + + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class CommentsResponse { + + private List content; + private boolean hasNext; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class CommentsUnitInfo{ + + private Long id; + private String content; + private Long memberId; + private String memberNickname; + private String memberImageUrl; + private int childCount; + private String createdAt; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class CommentUpdateResponse { + private String message; + } + + @Getter + @ToString + @AllArgsConstructor + public static class CommentDeleteResponse { + + private final String message; + } + + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/interfaces/dto/mapper/CommentMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/interfaces/dto/mapper/CommentMapper.java new file mode 100644 index 000000000..844af0b7d --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/comment/interfaces/dto/mapper/CommentMapper.java @@ -0,0 +1,30 @@ +package kr.co.yigil.comment.interfaces.dto.mapper; + + +import kr.co.yigil.comment.domain.CommentCommand; +import kr.co.yigil.comment.domain.CommentInfo; +import kr.co.yigil.comment.interfaces.dto.CommentDto; +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface CommentMapper { + + //request + CommentCommand.CommentCreateRequest of(CommentDto.CommentCreateRequest commentCreateRequest); + + CommentCommand.CommentUpdateRequest of(CommentDto.CommentUpdateRequest commentUpdateRequest); + + //response + + CommentDto.CommentsResponse of(CommentInfo.CommentsResponse commentsResponse); + + CommentDto.CommentDeleteResponse of(CommentInfo.DeleteResponse commentDeleteResponse); + + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/comment/presentation/CommentController.java b/backend/yigil-api/src/main/java/kr/co/yigil/comment/presentation/CommentController.java deleted file mode 100644 index 15d8adb30..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/comment/presentation/CommentController.java +++ /dev/null @@ -1,66 +0,0 @@ -package kr.co.yigil.comment.presentation; - -import java.util.List; -import kr.co.yigil.auth.Auth; -import kr.co.yigil.auth.MemberOnly; -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.comment.application.CommentService; -import kr.co.yigil.comment.dto.request.CommentCreateRequest; -import kr.co.yigil.comment.dto.response.CommentCreateResponse; -import kr.co.yigil.comment.dto.response.CommentDeleteResponse; -import kr.co.yigil.comment.dto.response.CommentResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/comments") -@RequiredArgsConstructor -public class CommentController { - private final CommentService commentService; - - @PostMapping("/{post_id}") - @MemberOnly - public ResponseEntity createComment( - @RequestBody CommentCreateRequest commentCreateRequest, - @Auth final Accessor accessor, - @PathVariable("post_id") Long postId - ){ - CommentCreateResponse commentCreateResponse = commentService.createComment(accessor.getMemberId(), postId, commentCreateRequest); - return ResponseEntity.ok().body(commentCreateResponse); - } - @GetMapping("/{post_id}") - public ResponseEntity> getTopCommentList( - @PathVariable("post_id") Long postId - ){ - List commentListResponse = commentService.getTopLevelCommentList(postId); - return ResponseEntity.ok().body(commentListResponse); - } - - @GetMapping("/{post_id}/{comment_id}") - public ResponseEntity> getReplyCommentList( - @PathVariable("post_id") Long postId, - @PathVariable("comment_id") Long commentId - ){ - List commentListResponse = commentService.getReplyCommentList(postId, commentId); - return ResponseEntity.ok().body(commentListResponse); - } - - @DeleteMapping("/{post_id}/{comment_id}") - @MemberOnly - public ResponseEntity deleteComment( - @PathVariable("comment_id") Long commentId, - @PathVariable("post_id") Long postId, - @Auth final Accessor accessor - ){ - CommentDeleteResponse commentDeleteResponse = commentService.deleteComment(accessor.getMemberId(), postId, commentId); - return ResponseEntity.ok().body(commentDeleteResponse); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/application/FavorFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/application/FavorFacade.java new file mode 100644 index 000000000..51fb73ac5 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/application/FavorFacade.java @@ -0,0 +1,27 @@ +package kr.co.yigil.favor.application; + +import kr.co.yigil.favor.domain.FavorInfo; +import kr.co.yigil.favor.domain.FavorService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FavorFacade { + + private final FavorService favorService; + private final NotificationService notificationService; + public FavorInfo.AddFavorResponse addFavor(final Long memberId, final Long travelId) { + Long ownerId = favorService.addFavor(memberId, travelId); + + notificationService.sendNotification(NotificationType.FAVOR, memberId, ownerId); + return new FavorInfo.AddFavorResponse("좋아요가 완료되었습니다."); + } + + public FavorInfo.DeleteFavorResponse deleteFavor(final Long memberId, final Long travelId) { + favorService.deleteFavor(memberId, travelId); + return new FavorInfo.DeleteFavorResponse("좋아요가 취소되었습니다."); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/application/FavorRedisIntegrityService.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/application/FavorRedisIntegrityService.java deleted file mode 100644 index b73dca11e..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/favor/application/FavorRedisIntegrityService.java +++ /dev/null @@ -1,34 +0,0 @@ -package kr.co.yigil.favor.application; - -import jakarta.transaction.Transactional; -import java.util.Optional; -import kr.co.yigil.favor.domain.FavorCount; -import kr.co.yigil.favor.domain.repository.FavorCountRepository; -import kr.co.yigil.favor.domain.repository.FavorRepository; -import kr.co.yigil.post.domain.Post; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class FavorRedisIntegrityService { - private final FavorRepository favorRepository; - private final FavorCountRepository favorCountRepository; - - @Transactional - public FavorCount ensureFavorCounts(Post post) { - Long postId = post.getId(); - Optional existingFavorCount = favorCountRepository.findById(postId); - if (existingFavorCount.isPresent()) { - return existingFavorCount.get(); - } else { - FavorCount favorCount = getFavorCount(post); - favorCountRepository.save(favorCount); - return favorCount; - } - } - - private FavorCount getFavorCount(Post post) { - return new FavorCount(post.getId(), favorRepository.countByPostId(post.getId())); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/application/FavorService.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/application/FavorService.java deleted file mode 100644 index 4ba8ce6b1..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/favor/application/FavorService.java +++ /dev/null @@ -1,89 +0,0 @@ -package kr.co.yigil.favor.application; - -import jakarta.transaction.Transactional; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; -import kr.co.yigil.favor.domain.Favor; -import kr.co.yigil.favor.domain.repository.FavorCountRepository; -import kr.co.yigil.favor.domain.repository.FavorRepository; -import kr.co.yigil.favor.dto.response.AddFavorResponse; -import kr.co.yigil.favor.dto.response.DeleteFavorResponse; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.repository.MemberRepository; -import kr.co.yigil.notification.application.NotificationService; -import kr.co.yigil.notification.domain.Notification; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.post.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class FavorService { - - private final MemberRepository memberRepository; - private final PostRepository postRepository; - private final FavorRepository favorRepository; - private final FavorCountRepository favorCountRepository; - private final NotificationService notificationService; - private final FavorRedisIntegrityService favorRedisIntegrityService; - - @Transactional - public AddFavorResponse addFavor(final Long memberId, final Long postId) { - Member member = getMemberById(memberId); - Post post = getPostById(postId); - - favorRedisIntegrityService.ensureFavorCounts(post); - - favorRepository.save(new Favor(member, post)); - incrementFavorCount(postId); - - sendFavorNotification(post, member); - - return new AddFavorResponse("좋아요가 완료되었습니다."); - } - - @Transactional - public DeleteFavorResponse deleteFavor(final Long memberId, final Long postId) { - Member member = getMemberById(memberId); - Post post = getPostById(postId); - - favorRedisIntegrityService.ensureFavorCounts(post); - - favorRepository.deleteByMemberAndPost(member, post); - decrementFavorCount(postId); - return new DeleteFavorResponse("좋아요가 취소되었습니다."); - } - - private void sendFavorNotification(Post post, Member member) { - String message = member.getNickname() + "님이 게시글에 좋아요를 눌렀습니다."; - Notification notify = new Notification(post.getMember(), message); - notificationService.sendNotification(notify); - } - - private Member getMemberById(Long memberId) { - return memberRepository.findById(memberId) - .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_MEMBER_ID)); - } - - private Post getPostById(Long postId) { - return postRepository.findById(postId) - .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_POST_ID)); - } - - private void incrementFavorCount(Long postId) { - favorCountRepository.findById(postId) - .ifPresent(favorCount -> { - favorCount.incrementFavorCount(); - favorCountRepository.save(favorCount); - }); - } - - private void decrementFavorCount(Long postId) { - favorCountRepository.findById(postId) - .ifPresent(favorCount -> { - favorCount.decrementFavorCount(); - favorCountRepository.save(favorCount); - }); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorCount.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorCount.java deleted file mode 100644 index 4d87b9db8..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorCount.java +++ /dev/null @@ -1,21 +0,0 @@ -package kr.co.yigil.favor.domain; - -import org.springframework.data.annotation.Id; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.data.redis.core.RedisHash; - -@Getter -@AllArgsConstructor -@RedisHash("favorCount") -public class FavorCount { - - @Id - private Long postId; - - private int favorCount; - - public void incrementFavorCount() { favorCount++; } - - public void decrementFavorCount() { favorCount--; } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorCountCacheReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorCountCacheReader.java new file mode 100644 index 000000000..a2db88df9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorCountCacheReader.java @@ -0,0 +1,6 @@ +package kr.co.yigil.favor.domain; + +public interface FavorCountCacheReader { + + int getFavorCount(Long favorId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorCountCacheStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorCountCacheStore.java new file mode 100644 index 000000000..d1a55ed50 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorCountCacheStore.java @@ -0,0 +1,6 @@ +package kr.co.yigil.favor.domain; + +public interface FavorCountCacheStore { + int incrementFavorCount(Long favorId); + int decrementFavorCount(Long favorId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorInfo.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorInfo.java new file mode 100644 index 000000000..951088a44 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorInfo.java @@ -0,0 +1,30 @@ +package kr.co.yigil.favor.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +public class FavorInfo { + + + @Getter + @ToString + @RequiredArgsConstructor + public static class AddFavorResponse { + private String message; + public AddFavorResponse(String message) { + this.message = message; + } + } + + @Getter + @ToString + @RequiredArgsConstructor + public static class DeleteFavorResponse{ + private String message; + public DeleteFavorResponse(String message) { + this.message = message; + } + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorReader.java new file mode 100644 index 000000000..f8922f248 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorReader.java @@ -0,0 +1,12 @@ +package kr.co.yigil.favor.domain; + +import kr.co.yigil.member.Member; +import kr.co.yigil.travel.domain.Travel; + +public interface FavorReader { + + boolean existsByMemberIdAndTravelId(Long memberId, Long travelId); + + Long getFavorIdByMemberAndTravel(Member member, Travel travel); + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorService.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorService.java new file mode 100644 index 000000000..e23ab2d9f --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorService.java @@ -0,0 +1,8 @@ +package kr.co.yigil.favor.domain; + +public interface FavorService { + + Long addFavor(Long userId, Long travelId); + + void deleteFavor(Long memberId, Long travelId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorServiceImpl.java new file mode 100644 index 000000000..ca2d22a55 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorServiceImpl.java @@ -0,0 +1,45 @@ +package kr.co.yigil.favor.domain; + +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.travel.domain.Travel; +import kr.co.yigil.travel.domain.TravelReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class FavorServiceImpl implements FavorService{ + + private final FavorReader favorReader; + private final MemberReader memberReader; + private final FavorStore favorStore; + private final TravelReader travelReader; + private final FavorCountCacheStore favorCountCacheStore; + + @Override + @Transactional + public Long addFavor(Long memberId, Long travelId) { + if(favorReader.existsByMemberIdAndTravelId(memberId, travelId)) + throw new BadRequestException(ExceptionCode.ALREADY_FAVOR); + + Member member = memberReader.getMember(memberId); + Travel travel = travelReader.getTravel(travelId); + favorStore.save(new Favor(member, travel)); + favorCountCacheStore.incrementFavorCount(travelId); + return travel.getMember().getId(); + } + + @Override + @Transactional + public void deleteFavor(Long memberId, Long travelId) { + Member member = memberReader.getMember(memberId); + Travel travel = travelReader.getTravel(travelId); + Long favorId = favorReader.getFavorIdByMemberAndTravel(member, travel); + favorStore.deleteFavorById(favorId); + favorCountCacheStore.decrementFavorCount(travelId); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorStore.java new file mode 100644 index 000000000..f99aeb5dd --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/FavorStore.java @@ -0,0 +1,8 @@ +package kr.co.yigil.favor.domain; + +public interface FavorStore { + + void save(Favor favor); + + void deleteFavorById(Long favorId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/repository/FavorCountRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/repository/FavorCountRepository.java deleted file mode 100644 index 9494e49d3..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/repository/FavorCountRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.co.yigil.favor.domain.repository; - -import kr.co.yigil.favor.domain.FavorCount; -import org.springframework.data.repository.CrudRepository; - -public interface FavorCountRepository extends CrudRepository { - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/repository/FavorRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/repository/FavorRepository.java deleted file mode 100644 index 5ac208631..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/favor/domain/repository/FavorRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.co.yigil.favor.domain.repository; - -import kr.co.yigil.favor.domain.Favor; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface FavorRepository extends JpaRepository { - - public int countByPostId(Long postId); - - public void deleteByMemberAndPost(Member member, Post post); -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorCountCacheReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorCountCacheReaderImpl.java new file mode 100644 index 000000000..1171cb098 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorCountCacheReaderImpl.java @@ -0,0 +1,20 @@ +package kr.co.yigil.favor.infrastructure; + +import kr.co.yigil.favor.domain.FavorCountCacheReader; +import kr.co.yigil.favor.domain.repository.FavorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class FavorCountCacheReaderImpl implements FavorCountCacheReader { + + private final FavorRepository favorRepository; + @Override + @Cacheable(value = "favorCount") + public int getFavorCount(Long travelId) { + return favorRepository.countByTravelId(travelId); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorCountCacheStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorCountCacheStoreImpl.java new file mode 100644 index 000000000..3eb4ad320 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorCountCacheStoreImpl.java @@ -0,0 +1,29 @@ +package kr.co.yigil.favor.infrastructure; + +import kr.co.yigil.favor.domain.FavorCountCacheReader; +import kr.co.yigil.favor.domain.FavorCountCacheStore; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CachePut; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class FavorCountCacheStoreImpl implements FavorCountCacheStore { + + private final FavorCountCacheReader favorCountCacheReader; + + @Override + @CachePut(value = "favorCount") + public int incrementFavorCount(Long favorId) { + var favorCount = favorCountCacheReader.getFavorCount(favorId); + return ++favorCount; + } + + @Override + @CachePut(value = "favorCount") + public int decrementFavorCount(Long favorId) { + var favorCount = favorCountCacheReader.getFavorCount(favorId); + return --favorCount; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorReaderImpl.java new file mode 100644 index 000000000..d906aed3d --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorReaderImpl.java @@ -0,0 +1,28 @@ +package kr.co.yigil.favor.infrastructure; + +import kr.co.yigil.favor.domain.FavorReader; +import kr.co.yigil.favor.domain.repository.FavorRepository; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.member.Member; +import kr.co.yigil.travel.domain.Travel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FavorReaderImpl implements FavorReader { + private final FavorRepository favorRepository; + + @Override + public boolean existsByMemberIdAndTravelId(Long memberId, Long travelId) { + return favorRepository.existsByMemberIdAndTravelId(memberId, travelId); + } + + @Override + public Long getFavorIdByMemberAndTravel(Member member, Travel travel) { + var favor = favorRepository.findFavorByMemberAndTravel(member, travel) + .orElseThrow(() -> new BadRequestException(ExceptionCode.FAVOR_NOT_FOUND)); + return favor.getId(); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorStoreImpl.java new file mode 100644 index 000000000..1ffab4729 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/infrastructure/FavorStoreImpl.java @@ -0,0 +1,23 @@ +package kr.co.yigil.favor.infrastructure; + +import kr.co.yigil.favor.domain.Favor; +import kr.co.yigil.favor.domain.FavorStore; +import kr.co.yigil.favor.domain.repository.FavorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FavorStoreImpl implements FavorStore { + private final FavorRepository favorRepository; + + @Override + public void save(Favor favor) { + favorRepository.save(favor); + } + + @Override + public void deleteFavorById(Long favorId) { + favorRepository.deleteById(favorId); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/interfaces/controller/FavorApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/interfaces/controller/FavorApiController.java new file mode 100644 index 000000000..f863ba8c0 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/interfaces/controller/FavorApiController.java @@ -0,0 +1,39 @@ +package kr.co.yigil.favor.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.favor.application.FavorFacade; +import kr.co.yigil.favor.interfaces.dto.FavorDto; +import kr.co.yigil.favor.interfaces.dto.mapper.FavorMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class FavorApiController { + + private final FavorFacade favorFacade; + private final FavorMapper favorMapper; + + @PostMapping("/api/v1/like/{travelId}") + @MemberOnly + public ResponseEntity addFavor(@Auth final Accessor accessor, + @PathVariable("travelId") final Long travelId) { + var responseInfo = favorFacade.addFavor(accessor.getMemberId(), travelId); + var response = favorMapper.of(responseInfo); + return ResponseEntity.ok().body(response); + } + + @PostMapping("/api/v1/unlike/{travelId}") + @MemberOnly + public ResponseEntity deleteFavor(@Auth final Accessor accessor, + @PathVariable("travelId") final Long travelId) { + var responseInfo = favorFacade.deleteFavor(accessor.getMemberId(), travelId); + var response = favorMapper.of(responseInfo); + return ResponseEntity.ok().body(response); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/interfaces/dto/FavorDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/interfaces/dto/FavorDto.java new file mode 100644 index 000000000..335547508 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/interfaces/dto/FavorDto.java @@ -0,0 +1,21 @@ +package kr.co.yigil.favor.interfaces.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +public class FavorDto { + @Getter + @Builder + @ToString + public static class AddFavorResponse { + private String message; + } + + @Getter + @Builder + @ToString + public static class DeleteFavorResponse { + private String message; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/interfaces/dto/mapper/FavorMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/interfaces/dto/mapper/FavorMapper.java new file mode 100644 index 000000000..ad64598f9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/favor/interfaces/dto/mapper/FavorMapper.java @@ -0,0 +1,19 @@ +package kr.co.yigil.favor.interfaces.dto.mapper; + +import kr.co.yigil.favor.domain.FavorInfo; +import kr.co.yigil.favor.interfaces.dto.FavorDto; +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface FavorMapper { + + FavorDto.AddFavorResponse of(FavorInfo.AddFavorResponse response); + FavorDto.DeleteFavorResponse of(FavorInfo.DeleteFavorResponse response); + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/favor/presentation/FavorController.java b/backend/yigil-api/src/main/java/kr/co/yigil/favor/presentation/FavorController.java deleted file mode 100644 index 2e4f94dca..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/favor/presentation/FavorController.java +++ /dev/null @@ -1,36 +0,0 @@ -package kr.co.yigil.favor.presentation; - -import kr.co.yigil.auth.Auth; -import kr.co.yigil.auth.MemberOnly; -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.favor.application.FavorService; -import kr.co.yigil.favor.dto.response.AddFavorResponse; -import kr.co.yigil.favor.dto.response.DeleteFavorResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class FavorController { - - private final FavorService favorService; - - @PostMapping("/api/v1/like/{postId}") - @MemberOnly - public ResponseEntity addFavor(@Auth final Accessor accessor, - @PathVariable("postId") final Long postId) { - AddFavorResponse response = favorService.addFavor(accessor.getMemberId(), postId); - return ResponseEntity.ok().body(response); - } - - @PostMapping("/api/v1/unlike/{postId}") - @MemberOnly - public ResponseEntity deleteFavor(@Auth final Accessor accessor, - @PathVariable("postId") final Long postId) { - DeleteFavorResponse response = favorService.deleteFavor(accessor.getMemberId(), postId); - return ResponseEntity.ok().body(response); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/file/FileReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/file/FileReader.java new file mode 100644 index 000000000..488a30ab8 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/file/FileReader.java @@ -0,0 +1,5 @@ +package kr.co.yigil.file; + +public interface FileReader { + AttachFile getFileByUrl(String url); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploadEvent.java b/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploadEvent.java index c09e18661..126ed4c53 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploadEvent.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploadEvent.java @@ -9,12 +9,13 @@ @Getter public class FileUploadEvent extends ApplicationEvent { + private static final long MAX_IMAGE_SIZE = 10485760; private static final long MAX_VIDEO_SIZE = MAX_IMAGE_SIZE * 5; - private MultipartFile file; - private FileType fileType; - private Consumer callback; + private final MultipartFile file; + private final FileType fileType; + private final Consumer callback; public FileUploadEvent(Object source, MultipartFile file, Consumer callback) { super(source); @@ -26,11 +27,13 @@ public FileUploadEvent(Object source, MultipartFile file, Consumer callb private void validateFileSize(FileType fileType, long size) { long maxSize = fileType == FileType.IMAGE ? MAX_IMAGE_SIZE : MAX_VIDEO_SIZE; - if(size > maxSize) throw new FileException(ExceptionCode.EXCEED_FILE_CAPACITY); + if (size > maxSize) { + throw new FileException(ExceptionCode.EXCEED_FILE_CAPACITY); + } } private FileType determineFileType(MultipartFile file) { - if (file.getContentType() == null) throw new FileException(ExceptionCode.EMPTY_FILE); + if (file == null) throw new FileException(ExceptionCode.EMPTY_FILE); if(file.getContentType().startsWith("image/")) { return FileType.IMAGE; @@ -41,6 +44,5 @@ private FileType determineFileType(MultipartFile file) { } throw new FileException(ExceptionCode.INVALID_FILE_TYPE); - } - + } } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploadEventListener.java b/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploadEventListener.java index 978978a75..fe4384158 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploadEventListener.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploadEventListener.java @@ -7,8 +7,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import lombok.RequiredArgsConstructor; - -import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -16,13 +15,12 @@ @Service @RequiredArgsConstructor +@Slf4j public class FileUploadEventListener { private final AmazonS3Client amazonS3Client; - private final String bucketName = "cdn.yigil.co.kr"; - @Async @EventListener public Future handleFileUpload(FileUploadEvent event) throws IOException { @@ -33,9 +31,11 @@ public Future handleFileUpload(FileUploadEvent event) throws IOException ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + metadata.setContentDisposition("inline"); amazonS3Client.putObject(bucketName, s3Path, file.getInputStream(), metadata); - event.getCallback().accept(s3Path); + event.getCallback().accept(s3Path); return CompletableFuture.completedFuture(s3Path); } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploader.java b/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploader.java new file mode 100644 index 000000000..64cf41c8b --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/file/FileUploader.java @@ -0,0 +1,9 @@ +package kr.co.yigil.file; + +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +public interface FileUploader { + AttachFile upload(MultipartFile file); + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/file/infrastructure/FileReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/file/infrastructure/FileReaderImpl.java new file mode 100644 index 000000000..52a52ddef --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/file/infrastructure/FileReaderImpl.java @@ -0,0 +1,22 @@ +package kr.co.yigil.file.infrastructure; + +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.FileReader; +import kr.co.yigil.file.repository.AttachFileRepository; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FileReaderImpl implements FileReader { + + private final AttachFileRepository attachFileRepository; + + @Override + public AttachFile getFileByUrl(String url) { + return attachFileRepository.findAttachFileByFileUrl(url) + .orElseThrow(() -> new BadRequestException(ExceptionCode.INVALID_FILE_URL)); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/file/infrastructure/FileUploaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/file/infrastructure/FileUploaderImpl.java new file mode 100644 index 000000000..0c9e64b31 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/file/infrastructure/FileUploaderImpl.java @@ -0,0 +1,48 @@ +package kr.co.yigil.file.infrastructure; + +import java.util.concurrent.CompletableFuture; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.FileType; +import kr.co.yigil.file.FileUploadEvent; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.global.exception.FileException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +@RequiredArgsConstructor +public class FileUploaderImpl implements FileUploader { + + private final ApplicationEventPublisher eventPublisher; + + @Override + public AttachFile upload(MultipartFile file) { + CompletableFuture fileUploadResult = new CompletableFuture<>(); + + FileUploadEvent event = new FileUploadEvent(this, file, fileUploadResult::complete); + eventPublisher.publishEvent(event); + + String fileUrl = fileUploadResult.join(); + FileType fileType = determineFileType(file); + + return new AttachFile(fileType, fileUrl, file.getOriginalFilename(), file.getSize()); + } + + private FileType determineFileType(MultipartFile file) { + if (file == null) throw new FileException(ExceptionCode.EMPTY_FILE); + + if(file.getContentType().startsWith("image/")) { + return FileType.IMAGE; + } + + if(file.getContentType().startsWith("video/")) { + return FileType.VIDEO; + } + + throw new FileException(ExceptionCode.INVALID_FILE_TYPE); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/application/FollowFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/application/FollowFacade.java new file mode 100644 index 000000000..9e8646fed --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/application/FollowFacade.java @@ -0,0 +1,36 @@ +package kr.co.yigil.follow.application; + +import kr.co.yigil.follow.domain.FollowInfo; +import kr.co.yigil.follow.domain.FollowInfo.FollowingsResponse; +import kr.co.yigil.follow.domain.FollowService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FollowFacade { + + private final FollowService followService; + private final NotificationService notificationService; + + public void follow(Long followerId, Long followingId) { + followService.follow(followerId, followingId); + notificationService.sendNotification(NotificationType.FOLLOW, followerId, followingId); + } + + public void unfollow(Long unfollowerId, Long unfollowingId) { + followService.unfollow(unfollowerId, unfollowingId); + notificationService.sendNotification(NotificationType.UNFOLLOW, unfollowerId, unfollowingId); + } + + public FollowInfo.FollowersResponse getFollowerList(final Long memberId, Pageable pageable) { + return followService.getFollowerList(memberId, pageable); + } + + public FollowingsResponse getFollowingList(final Long memberId, Pageable pageable) { + return followService.getFollowingList(memberId, pageable); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/application/FollowRedisIntegrityService.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/application/FollowRedisIntegrityService.java deleted file mode 100644 index 96d24cdcd..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/application/FollowRedisIntegrityService.java +++ /dev/null @@ -1,37 +0,0 @@ -package kr.co.yigil.follow.application; - -import jakarta.transaction.Transactional; -import java.util.Optional; -import kr.co.yigil.follow.domain.FollowCount; -import kr.co.yigil.follow.domain.repository.FollowCountRepository; -import kr.co.yigil.follow.domain.repository.FollowRepository; -import kr.co.yigil.follow.dto.FollowCountDto; -import kr.co.yigil.member.domain.Member; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class FollowRedisIntegrityService { - private final FollowRepository followRepository; - private final FollowCountRepository followCountRepository; - - @Transactional - public FollowCount ensureFollowCounts(Member member) { - Long memberId = member.getId(); - Optional existingFollowCount = followCountRepository.findById(memberId); - if (existingFollowCount.isPresent()) { - return existingFollowCount.get(); - } else { - FollowCount followCount = getFollowCount(member); - followCountRepository.save(followCount); - return followCount; - } - } - - private FollowCount getFollowCount(Member member) { - FollowCountDto followCountDto = followRepository.getFollowCounts(member); - return new FollowCount(member.getId(), followCountDto.getFollowerCount(), - followCountDto.getFollowingCount()); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/application/FollowService.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/application/FollowService.java deleted file mode 100644 index 636d78501..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/application/FollowService.java +++ /dev/null @@ -1,102 +0,0 @@ -package kr.co.yigil.follow.application; - -import jakarta.transaction.Transactional; -import kr.co.yigil.follow.domain.Follow; -import kr.co.yigil.follow.domain.repository.FollowCountRepository; -import kr.co.yigil.follow.domain.repository.FollowRepository; -import kr.co.yigil.follow.dto.response.FollowResponse; -import kr.co.yigil.follow.dto.response.UnfollowResponse; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.repository.MemberRepository; -import kr.co.yigil.notification.application.NotificationService; -import kr.co.yigil.notification.domain.Notification; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class FollowService { - - private final FollowRepository followRepository; - private final FollowCountRepository followCountRepository; - private final MemberRepository memberRepository; - private final NotificationService notificationService; - private final FollowRedisIntegrityService followRedisIntegrityService; - - @Transactional - public FollowResponse follow(final Long followerId, final Long followingId) { - Member follower = getMemberById(followerId); - Member following = getMemberById(followingId); - - followRedisIntegrityService.ensureFollowCounts(follower); - followRedisIntegrityService.ensureFollowCounts(following); - - followRepository.save(new Follow(follower, following)); - incrementFollowersCount(followingId); - incrementFollowingsCount(followerId); - - sendFollowNotification(follower, following); - - return new FollowResponse("팔로우가 완료되었습니다."); - } - - @Transactional - public UnfollowResponse unfollow(final Long followerId, final Long followingId) { - Member unfollower = getMemberById(followerId); - Member unfollowing = getMemberById(followingId); - - followRedisIntegrityService.ensureFollowCounts(unfollower); - followRedisIntegrityService.ensureFollowCounts(unfollowing); - - followRepository.deleteByFollowerAndFollowing(unfollower, unfollowing); - decrementFollowersCount(followingId); - decrementFollowingsCount(followerId); - return new UnfollowResponse("팔로우가 취소되었습니다."); - } - - private Member getMemberById(Long memberId) { - return memberRepository.findById(memberId) - .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_MEMBER_ID)); - } - - private void sendFollowNotification(Member follower, Member following) { - String message = follower.getNickname() + "님이 팔로우 하였습니다."; - Notification notify = new Notification(following, message); - notificationService.sendNotification(notify); - } - - private void incrementFollowersCount(Long memberId) { - followCountRepository.findById(memberId) - .ifPresent(followCount -> { - followCount.incrementFollowerCount(); - followCountRepository.save(followCount); - }); - } - - private void decrementFollowersCount(Long memberId) { - followCountRepository.findById(memberId) - .ifPresent(followCount -> { - followCount.decrementFollowerCount(); - followCountRepository.save(followCount); - }); - } - - private void incrementFollowingsCount(Long memberId) { - followCountRepository.findById(memberId) - .ifPresent(followCount -> { - followCount.incrementFollowingCount(); - followCountRepository.save(followCount); - }); - } - - private void decrementFollowingsCount(Long memberId) { - followCountRepository.findById(memberId) - .ifPresent(followCount -> { - followCount.decrementFollowingCount(); - followCountRepository.save(followCount); - }); - } -} - diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowCacheReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowCacheReader.java new file mode 100644 index 000000000..cd0e90056 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowCacheReader.java @@ -0,0 +1,7 @@ +package kr.co.yigil.follow.domain; + +import org.springframework.cache.annotation.Cacheable; + +public interface FollowCacheReader { + FollowCount getFollowCount(Long memberId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowCacheStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowCacheStore.java new file mode 100644 index 000000000..7dc7127cd --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowCacheStore.java @@ -0,0 +1,13 @@ +package kr.co.yigil.follow.domain; + +public interface FollowCacheStore { + + FollowCount incrementFollowingsCount(Long memberId); + + FollowCount decrementFollowingsCount(Long memberId); + + FollowCount incrementFollowersCount(Long memberId); + + FollowCount decrementFollowersCount(Long memberId); + +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowInfo.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowInfo.java new file mode 100644 index 000000000..45545780d --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowInfo.java @@ -0,0 +1,69 @@ +package kr.co.yigil.follow.domain; + +import java.util.List; +import kr.co.yigil.member.Member; +import lombok.Getter; +import lombok.ToString; + +public class FollowInfo { + + private FollowInfo(){} + @Getter + @ToString + public static class FollowersResponse { + + private final List content; + private final boolean hasNext; + + public FollowersResponse(List content, boolean hasNext) { + this.content = content; + this.hasNext = hasNext; + } + } + + @Getter + @ToString + public static class FollowingsResponse { + + private final List content; + private final boolean hasNext; + + public FollowingsResponse(List content, boolean hasNext) { + this.content = content; + this.hasNext = hasNext; + } + } + + @Getter + @ToString + public static class FollowingInfo { + + private final Long memberId; + private final String nickname; + private final String profileImageUrl; + + public FollowingInfo(Member member) { + this.memberId = member.getId(); + this.nickname = member.getNickname(); + this.profileImageUrl = member.getProfileImageUrl(); + } + } + + @Getter + @ToString + public static class FollowerInfo { + + private final Long memberId; + private final String nickname; + private final String profileImageUrl; + private final boolean following; + + public FollowerInfo(Member member, boolean following) { + this.memberId = member.getId(); + this.nickname = member.getNickname(); + this.profileImageUrl = member.getProfileImageUrl(); + this.following = following; + } + + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowReader.java new file mode 100644 index 000000000..5090d802b --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowReader.java @@ -0,0 +1,15 @@ +package kr.co.yigil.follow.domain; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface FollowReader { + + FollowCount getFollowCount(Long memberId); + + boolean isFollowing(Long followerId, Long followingId); + + Slice getFollowerSlice(Long memberId, Pageable pageable); + + Slice getFollowingSlice(Long memberId, Pageable pageable); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowService.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowService.java new file mode 100644 index 000000000..bd53d92c1 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowService.java @@ -0,0 +1,15 @@ +package kr.co.yigil.follow.domain; + +import kr.co.yigil.follow.domain.FollowInfo.FollowingsResponse; +import org.springframework.data.domain.Pageable; + +public interface FollowService { + + void follow(Long followerId, Long followingId); + + void unfollow(Long unfollowerId, Long unfollowingId); + + FollowInfo.FollowersResponse getFollowerList(Long memberId, Pageable pageable); + + FollowingsResponse getFollowingList(Long memberId, Pageable pageable); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowServiceImpl.java new file mode 100644 index 000000000..1b25f663f --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowServiceImpl.java @@ -0,0 +1,82 @@ +package kr.co.yigil.follow.domain; + +import jakarta.transaction.Transactional; +import kr.co.yigil.follow.domain.FollowInfo.FollowerInfo; +import kr.co.yigil.follow.domain.FollowInfo.FollowersResponse; +import kr.co.yigil.follow.domain.FollowInfo.FollowingInfo; +import kr.co.yigil.follow.domain.FollowInfo.FollowingsResponse; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FollowServiceImpl implements FollowService { + + private final FollowReader followReader; + private final MemberReader memberReader; + private final FollowStore followStore; + private final FollowCacheStore followCacheStore; + + @Override + @Transactional + public void follow(Long followerId, Long followingId) { + if (followerId.equals(followingId)) { + throw new BadRequestException(ExceptionCode.FOLLOW_MYSELF); + } else if (followReader.isFollowing(followerId, followingId)) { + throw new BadRequestException(ExceptionCode.ALREADY_FOLLOWING); + } + + Member follower = memberReader.getMember(followerId); + Member following = memberReader.getMember(followingId); + + followStore.store(follower, following); + followCacheStore.incrementFollowersCount(followingId); + followCacheStore.incrementFollowingsCount(followerId); + + } + + @Override + @Transactional + public void unfollow(Long unfollowerId, Long unfollowingId) { + if (unfollowerId.equals(unfollowingId)) { + throw new BadRequestException(ExceptionCode.UNFOLLOW_MYSELF); + } else if (!followReader.isFollowing(unfollowerId, unfollowingId)) { + throw new BadRequestException(ExceptionCode.NOT_FOLLOWING); + } + Member unfollower = memberReader.getMember(unfollowerId); + Member unfollowing = memberReader.getMember(unfollowingId); + + followStore.remove(unfollower, unfollowing); + followCacheStore.decrementFollowersCount(unfollowingId); + followCacheStore.decrementFollowingsCount(unfollowerId); + } + + @Override + @Transactional + public FollowInfo.FollowersResponse getFollowerList(Long memberId, Pageable pageable) { + memberReader.validateMember(memberId); + var followerSlice = followReader.getFollowerSlice(memberId, pageable); + var followerList = followerSlice.getContent().stream() + .map(Follow::getFollower) + .map(follower -> new FollowerInfo(follower, followReader.isFollowing(memberId, follower.getId()))) + .toList(); + return new FollowersResponse(followerList, followerSlice.hasNext()); + } + + @Override + @Transactional + public FollowingsResponse getFollowingList(Long memberId, Pageable pageable) { + memberReader.validateMember(memberId); + var followingSlice = followReader.getFollowingSlice(memberId, pageable); + var followingList = followingSlice.getContent().stream() + .map(Follow::getFollowing) + .map(FollowingInfo::new) + .toList(); + return new FollowingsResponse(followingList, followingSlice.hasNext()); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowStore.java new file mode 100644 index 000000000..413a6dfb8 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/FollowStore.java @@ -0,0 +1,13 @@ +package kr.co.yigil.follow.domain; + +import jakarta.transaction.Transactional; +import kr.co.yigil.member.Member; + +public interface FollowStore { + + @Transactional + void store(Member follower, Member following); + + @Transactional + void remove(Member unfollower, Member unfollowing); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/repository/FollowCountRedisRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/repository/FollowCountRedisRepository.java deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/repository/FollowRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/repository/FollowRepository.java deleted file mode 100644 index 0752501a6..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/domain/repository/FollowRepository.java +++ /dev/null @@ -1,23 +0,0 @@ -package kr.co.yigil.follow.domain.repository; - -import java.util.List; -import kr.co.yigil.follow.domain.Follow; -import kr.co.yigil.follow.dto.FollowCountDto; -import kr.co.yigil.member.domain.Member; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface FollowRepository extends JpaRepository { - - public List findAllByFollowing(Member member); - - public List findAllByFollower(Member member); - - @Query("SELECT new kr.co.yigil.follow.dto.FollowCountDto(" + - " (SELECT COUNT(f1) FROM Follow f1 WHERE f1.following = :member), " + - " (SELECT COUNT(f2) FROM Follow f2 WHERE f2.follower = :member))") - FollowCountDto getFollowCounts(@Param("member") Member member); - - public void deleteByFollowerAndFollowing(Member Follower, Member Following); -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowCacheReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowCacheReaderImpl.java new file mode 100644 index 000000000..a9061cebe --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowCacheReaderImpl.java @@ -0,0 +1,23 @@ +package kr.co.yigil.follow.infrastructure; + +import kr.co.yigil.follow.domain.FollowCacheReader; +import kr.co.yigil.follow.domain.FollowCount; +import kr.co.yigil.follow.domain.FollowReader; +import kr.co.yigil.member.domain.MemberReader; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FollowCacheReaderImpl implements FollowCacheReader { + private final FollowReader followReader; + private final MemberReader memberReader; + + @Override + @Cacheable(value = "followCount") + public FollowCount getFollowCount(Long memberId) { + memberReader.validateMember(memberId); + return followReader.getFollowCount(memberId); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowCacheStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowCacheStoreImpl.java new file mode 100644 index 000000000..f014a6219 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowCacheStoreImpl.java @@ -0,0 +1,46 @@ +package kr.co.yigil.follow.infrastructure; + +import kr.co.yigil.follow.domain.FollowCacheReader; +import kr.co.yigil.follow.domain.FollowCacheStore; +import kr.co.yigil.follow.domain.FollowCount; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CachePut; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FollowCacheStoreImpl implements FollowCacheStore { + private final FollowCacheReader followCacheReader; + + @Override + @CachePut(value = "followCount") + public FollowCount incrementFollowingsCount(Long memberId) { + FollowCount followCount = followCacheReader.getFollowCount(memberId); + followCount.incrementFollowingCount(); + return followCount; + } + + @Override + @CachePut(value = "followCount") + public FollowCount decrementFollowingsCount(Long memberId) { + FollowCount followCount = followCacheReader.getFollowCount(memberId); + followCount.decrementFollowingCount(); + return followCount; + } + + @Override + @CachePut(value = "followCount") + public FollowCount incrementFollowersCount(Long memberId) { + FollowCount followCount = followCacheReader.getFollowCount(memberId); + followCount.incrementFollowerCount(); + return followCount; + } + + @Override + @CachePut(value = "followCount") + public FollowCount decrementFollowersCount(Long memberId) { + FollowCount followCount = followCacheReader.getFollowCount(memberId); + followCount.decrementFollowerCount(); + return followCount; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowReaderImpl.java new file mode 100644 index 000000000..f024938bd --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowReaderImpl.java @@ -0,0 +1,40 @@ +package kr.co.yigil.follow.infrastructure; + +import kr.co.yigil.follow.FollowCountDto; +import kr.co.yigil.follow.domain.Follow; +import kr.co.yigil.follow.domain.FollowCount; +import kr.co.yigil.follow.domain.FollowReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FollowReaderImpl implements FollowReader { + + private final FollowRepository followRepository; + + @Override + public FollowCount getFollowCount(Long memberId) { + FollowCountDto followCountDto = followRepository.getFollowCounts(memberId); + return new FollowCount(memberId, followCountDto.getFollowerCount(), + followCountDto.getFollowingCount()); + } + + @Override + public boolean isFollowing(Long followerId, Long followingId) { + return followRepository.existsByFollowerIdAndFollowingId(followerId, followingId); + } + + @Override + public Slice getFollowerSlice(Long memberId, Pageable pageable) { + return followRepository.findAllByFollowingId(memberId, pageable); + } + + @Override + public Slice getFollowingSlice(Long memberId, Pageable pageable) { + return followRepository.findAllByFollowerId(memberId, pageable); + + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowStoreImpl.java new file mode 100644 index 000000000..06a1638eb --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/infrastructure/FollowStoreImpl.java @@ -0,0 +1,25 @@ +package kr.co.yigil.follow.infrastructure; + +import kr.co.yigil.follow.domain.Follow; +import kr.co.yigil.follow.domain.FollowStore; +import kr.co.yigil.member.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FollowStoreImpl implements FollowStore { + private final FollowRepository followRepository; + + @Override + public void store(Member follower, Member following) { + followRepository.save(new Follow(follower, following)); + + } + + @Override + public void remove(Member unfollower, Member unfollowing) { + followRepository.deleteByFollowerAndFollowing(unfollower, unfollowing); + + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/controller/FollowApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/controller/FollowApiController.java new file mode 100644 index 000000000..a846d0470 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/controller/FollowApiController.java @@ -0,0 +1,122 @@ +package kr.co.yigil.follow.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.follow.application.FollowFacade; +import kr.co.yigil.follow.domain.FollowInfo; +import kr.co.yigil.follow.interfaces.dto.FollowResponse; +import kr.co.yigil.follow.interfaces.dto.UnfollowResponse; +import kr.co.yigil.follow.interfaces.dto.FollowDto.FollowersResponse; +import kr.co.yigil.follow.interfaces.dto.FollowDto.FollowingsResponse; +import kr.co.yigil.follow.interfaces.dto.FollowDtoMapper; +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/follows") +public class FollowApiController { + + private final FollowFacade followFacade; + private final FollowDtoMapper followDtoMapper; + + @PostMapping("/follow/{member_id}") + @MemberOnly + public ResponseEntity follow(@Auth final Accessor accessor, + @PathVariable("member_id") final Long memberId) { + followFacade.follow(accessor.getMemberId(), memberId); + return ResponseEntity.ok().body(new FollowResponse("팔로우 성공")); + } + + @PostMapping("/unfollow/{member_id}") + @MemberOnly + public ResponseEntity unfollow(@Auth final Accessor accessor, + @PathVariable("member_id") final Long memberId) { + + followFacade.unfollow(accessor.getMemberId(), memberId); + return ResponseEntity.ok().body(new UnfollowResponse("언팔로우 성공")); + } + + @GetMapping("/followers") + @MemberOnly + public ResponseEntity getMyFollowerList( + @Auth final Accessor accessor, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "id", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + FollowInfo.FollowersResponse followerListResponse = followFacade.getFollowerList( + accessor.getMemberId(), pageRequest); + var response = followDtoMapper.of(followerListResponse); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/followings") + @MemberOnly + public ResponseEntity getMyFollowingList( + @Auth final Accessor accessor, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "id", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + final FollowInfo.FollowingsResponse followingListResponse = followFacade.getFollowingList( + accessor.getMemberId(), pageRequest); + FollowingsResponse response = followDtoMapper.of(followingListResponse); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/{memberId}/followers") + public ResponseEntity getMemberFollowerList( + @PathVariable("memberId") final Long memberId, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "id", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + FollowInfo.FollowersResponse followerList = followFacade.getFollowerList(memberId, + pageRequest); + var response = followDtoMapper.of(followerList); + return ResponseEntity.ok(response); + } + + @GetMapping("/{memberId}/followings") + public ResponseEntity getMemberFollowingList( + @PathVariable("memberId") final Long memberId, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "id", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + FollowInfo.FollowingsResponse followingList = followFacade.getFollowingList(memberId, + pageRequest); + var response = followDtoMapper.of(followingList); + return ResponseEntity.ok(response); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/FollowDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/FollowDto.java new file mode 100644 index 000000000..58219b188 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/FollowDto.java @@ -0,0 +1,51 @@ +package kr.co.yigil.follow.interfaces.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +public class FollowDto { + + @Getter + @Builder + @ToString + public static class FollowersResponse { + + private final List content; + private final boolean hasNext; + } + + @Getter + @Builder + @ToString + public static class FollowingsResponse { + + private final List content; + private final boolean hasNext; + } + + @Getter + @Builder + @ToString + public static class FollowingInfo { + + private final Long memberId; + private final String nickname; + private final String profileImageUrl; + } + + @Getter + @Builder + @ToString + public static class FollowerInfo { + + private final Long memberId; + private final String nickname; + private final String profileImageUrl; + private final boolean following; + } + + + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/FollowDtoMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/FollowDtoMapper.java new file mode 100644 index 000000000..ad842dc0e --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/FollowDtoMapper.java @@ -0,0 +1,19 @@ +package kr.co.yigil.follow.interfaces.dto; + +import kr.co.yigil.follow.domain.FollowInfo; +import kr.co.yigil.follow.interfaces.dto.FollowDto.FollowersResponse; +import kr.co.yigil.follow.interfaces.dto.FollowDto.FollowingsResponse; +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface FollowDtoMapper { + FollowersResponse of (FollowInfo.FollowersResponse followersResponse); + + FollowingsResponse of (FollowInfo.FollowingsResponse followingsResponse); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/dto/response/FollowResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/FollowResponse.java similarity index 81% rename from backend/yigil-api/src/main/java/kr/co/yigil/follow/dto/response/FollowResponse.java rename to backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/FollowResponse.java index 3a45fdc24..0a9849be4 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/dto/response/FollowResponse.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/FollowResponse.java @@ -1,4 +1,4 @@ -package kr.co.yigil.follow.dto.response; +package kr.co.yigil.follow.interfaces.dto; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/dto/response/UnfollowResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/UnfollowResponse.java similarity index 85% rename from backend/yigil-api/src/main/java/kr/co/yigil/follow/dto/response/UnfollowResponse.java rename to backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/UnfollowResponse.java index 11335e039..df0198343 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/dto/response/UnfollowResponse.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/follow/interfaces/dto/UnfollowResponse.java @@ -1,4 +1,4 @@ -package kr.co.yigil.follow.dto.response; +package kr.co.yigil.follow.interfaces.dto; import jakarta.persistence.NamedStoredProcedureQueries; import lombok.AllArgsConstructor; diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/follow/presentation/FollowController.java b/backend/yigil-api/src/main/java/kr/co/yigil/follow/presentation/FollowController.java deleted file mode 100644 index b09d04c9a..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/follow/presentation/FollowController.java +++ /dev/null @@ -1,36 +0,0 @@ -package kr.co.yigil.follow.presentation; - -import kr.co.yigil.auth.Auth; -import kr.co.yigil.auth.MemberOnly; -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.follow.application.FollowService; -import kr.co.yigil.follow.dto.response.FollowResponse; -import kr.co.yigil.follow.dto.response.UnfollowResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class FollowController { - - private final FollowService followService; - - @PostMapping("/api/v1/follow/{memberId}") - @MemberOnly - public ResponseEntity follow(@Auth final Accessor accessor, - @PathVariable("memberId") final Long memberId) { - FollowResponse response = followService.follow(accessor.getMemberId(), memberId); - return ResponseEntity.ok().body(response); - } - - @PostMapping("/api/v1/unfollow/{memberId}") - @MemberOnly - public ResponseEntity unfollow(@Auth final Accessor accessor, - @PathVariable("memberId") final Long memberId) { - UnfollowResponse response = followService.unfollow(accessor.getMemberId(), memberId); - return ResponseEntity.ok().body(response); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/AsyncConfig.java b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/AsyncConfig.java index 1af468b06..dcbb328b9 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/AsyncConfig.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/AsyncConfig.java @@ -1,5 +1,6 @@ package kr.co.yigil.global.config; + import kr.co.yigil.decorator.MdcDecorator; import kr.co.yigil.global.exception.AsyncExceptionHandler; import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/JpaConfig.java b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/JpaConfig.java new file mode 100644 index 000000000..5e832f147 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/JpaConfig.java @@ -0,0 +1,10 @@ +package kr.co.yigil.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/LoginResolverConfig.java b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/LoginResolverConfig.java index 87508fc03..dc344c989 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/LoginResolverConfig.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/LoginResolverConfig.java @@ -1,8 +1,7 @@ package kr.co.yigil.global.config; import java.util.List; -import java.util.logging.Handler; -import kr.co.yigil.login.application.LoginArgumentResolver; +import kr.co.yigil.login.infrastructure.LoginArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/QueryDslConfig.java b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/QueryDslConfig.java new file mode 100644 index 000000000..fd64470ce --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/QueryDslConfig.java @@ -0,0 +1,20 @@ +package kr.co.yigil.global.config; + + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class QueryDslConfig { + + private final EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory(){ + return new JPAQueryFactory(em); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/RedisConfig.java b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/RedisConfig.java index 54ead2f89..c1bddd376 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/RedisConfig.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/RedisConfig.java @@ -1,6 +1,7 @@ package kr.co.yigil.global.config; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -14,6 +15,7 @@ import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; @Configuration +@EnableCaching @EnableRedisRepositories public class RedisConfig { @Value("${spring.data.redis.host}") diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/RestTemplateConfig.java b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/RestTemplateConfig.java new file mode 100644 index 000000000..0058486e8 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/RestTemplateConfig.java @@ -0,0 +1,19 @@ +package kr.co.yigil.global.config; + +import java.time.Duration; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(5)) + .build(); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/SecurityConfig.java b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/SecurityConfig.java index 4a98226e0..442ebbcc6 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/global/config/SecurityConfig.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/global/config/SecurityConfig.java @@ -1,19 +1,14 @@ package kr.co.yigil.global.config; -import java.util.Arrays; import java.util.List; -import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @@ -23,25 +18,20 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .cors(Customizer.withDefaults()) - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests((authorizeRequests) -> authorizeRequests.anyRequest().permitAll()) - .securityContext(AbstractHttpConfigurer::disable) - .sessionManagement((sessionManagement) -> sessionManagement.maximumSessions(1)); + .cors(corsCustomizer -> corsCustomizer.configurationSource(request -> { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:3000", "https://yigil.co.kr")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + return config; + })) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests((authorizeRequests) -> authorizeRequests.anyRequest().permitAll()) + .securityContext(AbstractHttpConfigurer::disable) + .sessionManagement((sessionManagement) -> sessionManagement.maximumSessions(1)); return http.build(); } - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowCredentials(true); - configuration.setAllowedOriginPatterns(List.of("*")); - configuration.addAllowedMethod(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS").toString()); - configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type")); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 적용 - return source; - } - -} +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/global/exception/ExceptionCode.java b/backend/yigil-api/src/main/java/kr/co/yigil/global/exception/ExceptionCode.java index c9e92f90d..121915ebe 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/global/exception/ExceptionCode.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/global/exception/ExceptionCode.java @@ -6,7 +6,6 @@ @RequiredArgsConstructor @Getter public enum ExceptionCode { - INVALID_REQUEST(1000, "올바르지 않은 요청입니다."), NOT_FOUND_MEMBER_ID(1001, "사용자를 찾을 수 없습니다."), NOT_FOUND_POST_ID(1011, "해당하는 post가 없습니다"), @@ -14,27 +13,45 @@ public enum ExceptionCode { NOT_FOUND_COURSE_ID(1031, "해당하는 course가 없습니다"), NOT_FOUND_TRAVEL_ID(1041, "해당하는 travel이 없습니다"), NOT_FOUND_COMMENT_ID(1051, "해당하는 comment가 없습니다"), + NOT_FOUND_PLACE_ID(1061, "해당하는 place가 없습니다"), + NOT_FOUND_FAVOR_COUNT(1071, "해당하는 favor count가 없습니다"), + NOT_FOUND_REGION_ID(1081, "해당하는 region이 없습니다"), + + + INVALID_VISIBILITY_REQUEST(3001, "올바르지 않은 visibility 요청입니다."), - //post 2000 + ALREADY_EXIST_SPOT(3001, "이미 등록된 spot입니다."), - // travel 3000 - TRAVEL_CASTING_ERROR(3001, "Travel Casting 오류가 발생했습니다."), - // spot 3100 + FOLLOW_MYSELF(4001, "자신을 follow 할 수 없습니다."), + ALREADY_FOLLOWING(4002, "이미 follow 중인 사용자입니다."), + UNFOLLOW_MYSELF(4003, "자신을 unfollow 할 수 없습니다."), + NOT_FOLLOWING(4004, "팔로우 중이 아닌 사용자입니다."), - // course 3200 + ALREADY_REMOVED_COMMENT(3301, "이미 삭제된 댓글입니다."), - EMPTY_FILE(5001, "업로드한 파일이 비어있습니다."), + EMPTY_FILE(5001, "업로드한 파일이 비어있습니다."), // todo 현재 모듈 구조상 AttachFiles에서 사용 불가 INVALID_FILE_TYPE(5002, "지원하지 않는 형식의 파일입니다."), EXCEED_FILE_CAPACITY(5003, "업로드 가능한 파일 용량을 초과했습니다."), + EXCEED_FILE_COUNT(5004, "업로드 가능한 파일 개수를 초과했습니다."), // todo 현재 모듈 구조상 AttachFiles에서 사용 불가 + INVALID_FILE_URL(5005, "유효한 파일 URL이 아닙니다."), // GeoJson -// INVALID_GEOMETRY_TYPE(6001, "geometry 타입이 다릅니다"), + // INVALID_GEOMETRY_TYPE(6001, "geometry 타입이 다릅니다"), INVALID_GEO_JSON_FORMAT(6002, "올바르지 않은 형식의 JSON String입니다."), GEO_JSON_CASTING_ERROR(6003, "JSON String Casting 오류가 발생했습니다."), INVALID_POINT_GEO_JSON(6011, "Point GeoJson 형식이 아닙니다"), INVALID_LINESTRING_GEO_JSON(6021, "Point GeoJson 형식이 아닙니다"), + + ALREADY_BOOKMARKED(7001, "이미 북마크된 장소입니다."), + NOT_BOOKMARKED(7002, "북마크되지 않은 장소입니다."), + + // Favor + FAVOR_NOT_FOUND(8001, "좋아요가 존재하지 않습니다." ), + ALREADY_FAVOR(8002, "이미 좋아요를 누른 게시글입니다."), + NOT_FAVOR(8003, "좋아요를 누르지 않은 게시글입니다."), + // Authorization & Authentication INVALID_ACCESS_TOKEN(9101, "올바르지 않은 형식의 Access Token입니다."), INVALID_AUTHORITY(9201, "해당 요청에 대한 접근 권한이 없습니다."), diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/global/exception/GlobalExceptionHandler.java b/backend/yigil-api/src/main/java/kr/co/yigil/global/exception/GlobalExceptionHandler.java index 6ba03e4d8..f0d24100d 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/global/exception/GlobalExceptionHandler.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/global/exception/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import java.util.Objects; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; @@ -21,10 +22,10 @@ @RestControllerAdvice @Slf4j +@RequiredArgsConstructor public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { - @Autowired - private HttpServletRequest request; + private final HttpServletRequest request; @Override protected ResponseEntity handleMethodArgumentNotValid( diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/LoginFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/application/LoginFacade.java new file mode 100644 index 000000000..35f352331 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/application/LoginFacade.java @@ -0,0 +1,18 @@ +package kr.co.yigil.login.application; + +import kr.co.yigil.login.domain.LoginCommand; +import kr.co.yigil.login.domain.LoginStrategyManager; +import kr.co.yigil.login.infrastructure.LoginStrategy; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class LoginFacade { + private final LoginStrategyManager loginStrategyManager; + + public Long executeLoginStrategy(LoginCommand.LoginRequest loginCommand, String accessToken) { + LoginStrategy strategy = loginStrategyManager.getLoginStrategy(loginCommand.getProvider()); + return strategy.processLogin(loginCommand, accessToken); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/strategy/LoginStrategy.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/application/strategy/LoginStrategy.java deleted file mode 100644 index 76ea2344a..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/strategy/LoginStrategy.java +++ /dev/null @@ -1,12 +0,0 @@ -package kr.co.yigil.login.application.strategy; - -import jakarta.servlet.http.HttpSession; -import kr.co.yigil.login.dto.request.LoginRequest; -import kr.co.yigil.login.dto.response.LoginResponse; - -public interface LoginStrategy { - LoginResponse login(LoginRequest request, String accessToken, HttpSession session); - - String getProviderName(); - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/domain/LoginCommand.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/domain/LoginCommand.java new file mode 100644 index 000000000..08b26d6e9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/domain/LoginCommand.java @@ -0,0 +1,32 @@ +package kr.co.yigil.login.domain; + +import kr.co.yigil.member.Member; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +public class LoginCommand { + + @Getter + @Builder + @ToString + public static class LoginRequest { + private final String id; + private final String nickname; + private final String profileImageUrl; + private final String email; + private final String provider; + + public Member toEntity(String providerName) { + return new Member( + email, + id, + nickname, + profileImageUrl, + providerName + ); + } + } + + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/LoginStrategyManager.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/domain/LoginStrategyManager.java similarity index 86% rename from backend/yigil-api/src/main/java/kr/co/yigil/login/application/LoginStrategyManager.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/domain/LoginStrategyManager.java index 414557fa6..430701b65 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/LoginStrategyManager.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/domain/LoginStrategyManager.java @@ -1,10 +1,10 @@ -package kr.co.yigil.login.application; +package kr.co.yigil.login.domain; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; -import kr.co.yigil.login.application.strategy.LoginStrategy; +import kr.co.yigil.login.infrastructure.LoginStrategy; import org.springframework.stereotype.Service; @Service diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/strategy/GoogleLoginStrategy.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/GoogleLoginStrategy.java similarity index 55% rename from backend/yigil-api/src/main/java/kr/co/yigil/login/application/strategy/GoogleLoginStrategy.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/GoogleLoginStrategy.java index 233be0165..4e3adcbd5 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/strategy/GoogleLoginStrategy.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/GoogleLoginStrategy.java @@ -1,16 +1,15 @@ -package kr.co.yigil.login.application.strategy; +package kr.co.yigil.login.infrastructure; import static kr.co.yigil.global.exception.ExceptionCode.INVALID_ACCESS_TOKEN; -import jakarta.servlet.http.HttpSession; import java.util.Collections; import kr.co.yigil.global.exception.InvalidTokenException; -import kr.co.yigil.login.dto.request.LoginRequest; -import kr.co.yigil.login.dto.response.GoogleTokenInfoResponse; -import kr.co.yigil.login.dto.response.LoginResponse; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.member.domain.repository.MemberRepository; +import kr.co.yigil.login.domain.LoginCommand; +import kr.co.yigil.login.interfaces.dto.response.GoogleTokenInfoResponse; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.member.domain.MemberStore; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -25,28 +24,29 @@ @Service @RequiredArgsConstructor @Slf4j -public class GoogleLoginStrategy implements LoginStrategy{ +public class GoogleLoginStrategy implements LoginStrategy { + private final MemberReader memberReader; + + private final MemberStore memberStore; private static final String PROVIDER_NAME = "google"; - private final MemberRepository memberRepository; @Setter private RestTemplate restTemplate = new RestTemplate(); @Override - public LoginResponse login(LoginRequest request, String accessToken, HttpSession session) { + public Long processLogin(LoginCommand.LoginRequest loginCommand, String accessToken) { - if(!isTokenValid(accessToken, request.getId())) { + if(!isTokenValid(accessToken, loginCommand.getEmail())) { throw new InvalidTokenException(INVALID_ACCESS_TOKEN); } - Member member = memberRepository.findMemberBySocialLoginIdAndSocialLoginType(request.getId().toString(), - SocialLoginType.valueOf(PROVIDER_NAME.toUpperCase())) - .orElseGet(() -> registerNewMember(request)); + Member member = memberReader.findMemberByEmailAndSocialLoginType( + loginCommand.getEmail(), SocialLoginType.GOOGLE) + .orElseGet(() -> registerNewMember(loginCommand)); - session.setAttribute("memberId", member.getId()); - return new LoginResponse("로그인 성공"); + return member.getId(); } @Override @@ -54,14 +54,14 @@ public String getProviderName() { return "google"; } - private boolean isTokenValid(String accessToken, Long expectedUserId) { + private boolean isTokenValid(String accessToken, String expectedUserEmail) { GoogleTokenInfoResponse tokenInfo = requestGoogleTokenInfo(accessToken); - return isUserIdValid(tokenInfo, expectedUserId); + return isUserIdValid(tokenInfo, expectedUserEmail); } private GoogleTokenInfoResponse requestGoogleTokenInfo(String accessToken) { HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Basic " + accessToken); + headers.set("Authorization", "Bearer " + accessToken); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); HttpEntity entity = new HttpEntity<>(headers); try { @@ -83,12 +83,12 @@ private GoogleTokenInfoResponse requestGoogleTokenInfo(String accessToken) { } } - private boolean isUserIdValid(GoogleTokenInfoResponse tokenInfo, Long expectedUserId) { - return tokenInfo != null && tokenInfo.getUserId().equals(expectedUserId); + private boolean isUserIdValid(GoogleTokenInfoResponse tokenInfo, String expectedUserEmail) { + return tokenInfo != null && tokenInfo.getEmail().equals(expectedUserEmail); } - private Member registerNewMember(LoginRequest request) { - Member newMember = new Member(request.getEmail(), request.getId().toString(), request.getNickname(), request.getProfileImageUrl(), PROVIDER_NAME); - return memberRepository.save(newMember); + private Member registerNewMember(LoginCommand.LoginRequest loginCommand) { + Member newMember = loginCommand.toEntity(PROVIDER_NAME); + return memberStore.save(newMember); } } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/strategy/KakaoLoginStrategy.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/KakaoLoginStrategy.java similarity index 62% rename from backend/yigil-api/src/main/java/kr/co/yigil/login/application/strategy/KakaoLoginStrategy.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/KakaoLoginStrategy.java index fbd5d5614..c0c036af5 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/strategy/KakaoLoginStrategy.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/KakaoLoginStrategy.java @@ -1,16 +1,15 @@ -package kr.co.yigil.login.application.strategy; +package kr.co.yigil.login.infrastructure; import static kr.co.yigil.global.exception.ExceptionCode.INVALID_ACCESS_TOKEN; -import jakarta.servlet.http.HttpSession; import java.util.Collections; import kr.co.yigil.global.exception.InvalidTokenException; -import kr.co.yigil.login.dto.request.LoginRequest; -import kr.co.yigil.login.dto.response.KakaoTokenInfoResponse; -import kr.co.yigil.login.dto.response.LoginResponse; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.member.domain.repository.MemberRepository; +import kr.co.yigil.login.domain.LoginCommand; +import kr.co.yigil.login.interfaces.dto.response.KakaoTokenInfoResponse; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.member.domain.MemberStore; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -29,30 +28,30 @@ @Slf4j @PropertySource("classpath:url.properties") public class KakaoLoginStrategy implements LoginStrategy { + private final MemberReader memberReader; + + private final MemberStore memberStore; private final static String PROVIDER_NAME = "kakao"; @Value("${kakao.token.info.url}") private String KAKAO_TOKEN_INFO_URL; - private final MemberRepository memberRepository; - @Setter private RestTemplate restTemplate = new RestTemplate(); @Override - public LoginResponse login(LoginRequest request, String accessToken, HttpSession session) { + public Long processLogin(LoginCommand.LoginRequest loginCommand, String accessToken) { - if(!isTokenValid(accessToken, request.getId())) { + if(!isTokenValid(accessToken, loginCommand.getId())) { throw new InvalidTokenException(INVALID_ACCESS_TOKEN); } - Member member = memberRepository.findMemberBySocialLoginIdAndSocialLoginType(request.getId().toString(), - SocialLoginType.valueOf(PROVIDER_NAME.toUpperCase())) - .orElseGet(() -> registerNewMember(request)); + Member member = memberReader.findMemberBySocialLoginIdAndSocialLoginType( + loginCommand.getId(), SocialLoginType.KAKAO) + .orElseGet(() -> registerNewMember(loginCommand)); - session.setAttribute("memberId", member.getId()); - return new LoginResponse("로그인 성공"); + return member.getId(); } @Override @@ -60,7 +59,7 @@ public String getProviderName() { return PROVIDER_NAME; } - private boolean isTokenValid(String accessToken, Long expectedUserId) { + private boolean isTokenValid(String accessToken, String expectedUserId) { KakaoTokenInfoResponse tokenInfo = requestKakaoTokenInfo(accessToken); return isUserIdValid(tokenInfo, expectedUserId); } @@ -86,13 +85,13 @@ private KakaoTokenInfoResponse requestKakaoTokenInfo(String accessToken) { } } - private boolean isUserIdValid(KakaoTokenInfoResponse tokenInfo, Long expectedUserId) { - return tokenInfo != null && tokenInfo.getId().equals(expectedUserId); + private boolean isUserIdValid(KakaoTokenInfoResponse tokenInfo, String expectedUserId) { + return tokenInfo != null && tokenInfo.getId().toString().equals(expectedUserId); } - private Member registerNewMember(LoginRequest request) { - Member newMember = new Member(request.getEmail(), request.getId().toString(), request.getNickname(), request.getProfileImageUrl(), PROVIDER_NAME); - return memberRepository.save(newMember); + private Member registerNewMember(LoginCommand.LoginRequest loginCommand) { + Member newMember = loginCommand.toEntity(PROVIDER_NAME); + return memberStore.save(newMember); } } \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/LoginArgumentResolver.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/LoginArgumentResolver.java similarity index 97% rename from backend/yigil-api/src/main/java/kr/co/yigil/login/application/LoginArgumentResolver.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/LoginArgumentResolver.java index 58e561447..eca91474a 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/application/LoginArgumentResolver.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/LoginArgumentResolver.java @@ -1,4 +1,4 @@ -package kr.co.yigil.login.application; +package kr.co.yigil.login.infrastructure; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/LoginStrategy.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/LoginStrategy.java new file mode 100644 index 000000000..200c216c1 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/infrastructure/LoginStrategy.java @@ -0,0 +1,10 @@ +package kr.co.yigil.login.infrastructure; + +import kr.co.yigil.login.domain.LoginCommand; + +public interface LoginStrategy { + Long processLogin(LoginCommand.LoginRequest loginCommand, String accessToken); + + String getProviderName(); + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/controller/LoginApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/controller/LoginApiController.java new file mode 100644 index 000000000..4853d5eac --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/controller/LoginApiController.java @@ -0,0 +1,47 @@ +package kr.co.yigil.login.interfaces.controller; + +import static kr.co.yigil.login.util.LoginUtils.extractToken; + +import jakarta.servlet.http.HttpSession; +import kr.co.yigil.login.application.LoginFacade; +import kr.co.yigil.login.domain.LoginCommand; +import kr.co.yigil.login.interfaces.dto.mapper.LoginMapper; +import kr.co.yigil.login.interfaces.dto.request.LoginRequest; +import kr.co.yigil.login.interfaces.dto.response.LoginResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class LoginApiController { + + private final LoginFacade loginFacade; + private final LoginMapper loginMapper; + + @PostMapping("/api/v1/login") + public ResponseEntity login( + @RequestHeader(value = "Authorization") String authorizationHeader, + @RequestBody LoginRequest loginRequest, + HttpSession session + ) { + String accessToken = extractToken(authorizationHeader); + LoginCommand.LoginRequest loginCommand = loginMapper.toCommandLoginRequest(loginRequest); + Long loginMemberId = loginFacade.executeLoginStrategy(loginCommand, accessToken); + session.setAttribute("memberId", loginMemberId); + + return ResponseEntity.ok(new LoginResponse("로그인 성공")); + } + + @GetMapping("/api/v1/logout") + public ResponseEntity logout( + HttpSession session + ) { + session.invalidate(); + return ResponseEntity.ok(new LoginResponse("로그아웃 성공")); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/presentation/testController.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/controller/TestApiController.java similarity index 50% rename from backend/yigil-api/src/main/java/kr/co/yigil/login/presentation/testController.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/controller/TestApiController.java index 4597c642b..66fc475c1 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/presentation/testController.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/controller/TestApiController.java @@ -1,26 +1,27 @@ -package kr.co.yigil.login.presentation; +package kr.co.yigil.login.interfaces.controller; import jakarta.servlet.http.HttpSession; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.member.domain.repository.MemberRepository; +import kr.co.yigil.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @Slf4j -public class testController { +public class TestApiController { private final MemberRepository repository; - @PostMapping("/test") - public ResponseEntity loginTest(HttpSession session) { - session.setAttribute("memberId", 1L); - log.error("test-error"); + @PostMapping("/test/{member_id}") + public ResponseEntity loginTest( + HttpSession session, + @PathVariable("member_id") Long memberId + ) { + session.setAttribute("memberId", memberId); return ResponseEntity.ok("로그인 성공"); } } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/mapper/LoginMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/mapper/LoginMapper.java new file mode 100644 index 000000000..d52cf4b6a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/mapper/LoginMapper.java @@ -0,0 +1,20 @@ +package kr.co.yigil.login.interfaces.dto.mapper; + +import kr.co.yigil.login.domain.LoginCommand; +import kr.co.yigil.login.interfaces.dto.request.LoginRequest; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +@Mapper(componentModel = "spring") +public interface LoginMapper { + + @Mappings({ + @Mapping(source = "id", target = "id"), + @Mapping(source = "nickname", target = "nickname"), + @Mapping(source = "profileImageUrl", target = "profileImageUrl"), + @Mapping(source = "email", target = "email"), + @Mapping(source = "provider", target = "provider") + }) + LoginCommand.LoginRequest toCommandLoginRequest(LoginRequest loginRequest); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/dto/request/LoginRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/request/LoginRequest.java similarity index 74% rename from backend/yigil-api/src/main/java/kr/co/yigil/login/dto/request/LoginRequest.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/request/LoginRequest.java index 2a4b7c0db..a14bae4f5 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/dto/request/LoginRequest.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/request/LoginRequest.java @@ -1,4 +1,4 @@ -package kr.co.yigil.login.dto.request; +package kr.co.yigil.login.interfaces.dto.request; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -9,10 +9,10 @@ @Data @AllArgsConstructor @NoArgsConstructor -@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) public class LoginRequest { - private Long id; + private String id; private String nickname; private String profileImageUrl; private String email; + private String provider; } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/dto/response/GoogleTokenInfoResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/GoogleTokenInfoResponse.java similarity index 83% rename from backend/yigil-api/src/main/java/kr/co/yigil/login/dto/response/GoogleTokenInfoResponse.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/GoogleTokenInfoResponse.java index 5e5dd7fea..e7c1344da 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/dto/response/GoogleTokenInfoResponse.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/GoogleTokenInfoResponse.java @@ -1,4 +1,4 @@ -package kr.co.yigil.login.dto.response; +package kr.co.yigil.login.interfaces.dto.response; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -11,6 +11,6 @@ @NoArgsConstructor @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) public class GoogleTokenInfoResponse { - private Long userId; + private String email; private int expiresIn; } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/dto/response/KakaoTokenInfoResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/KakaoTokenInfoResponse.java similarity index 82% rename from backend/yigil-api/src/main/java/kr/co/yigil/login/dto/response/KakaoTokenInfoResponse.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/KakaoTokenInfoResponse.java index 4d7ce1140..801830d44 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/dto/response/KakaoTokenInfoResponse.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/KakaoTokenInfoResponse.java @@ -1,4 +1,4 @@ -package kr.co.yigil.login.dto.response; +package kr.co.yigil.login.interfaces.dto.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/dto/response/LoginResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/LoginResponse.java similarity index 82% rename from backend/yigil-api/src/main/java/kr/co/yigil/login/dto/response/LoginResponse.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/LoginResponse.java index 2c9127fe1..4a71a4dda 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/dto/response/LoginResponse.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/LoginResponse.java @@ -1,4 +1,4 @@ -package kr.co.yigil.login.dto.response; +package kr.co.yigil.login.interfaces.dto.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/SpotCreateResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/LogoutResponse.java similarity index 67% rename from backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/SpotCreateResponse.java rename to backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/LogoutResponse.java index 9db9d68d2..0b6034f85 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/SpotCreateResponse.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/login/interfaces/dto/response/LogoutResponse.java @@ -1,4 +1,4 @@ -package kr.co.yigil.travel.dto.response; +package kr.co.yigil.login.interfaces.dto.response; import lombok.AllArgsConstructor; import lombok.Data; @@ -7,7 +7,7 @@ @Data @AllArgsConstructor @NoArgsConstructor -public class SpotCreateResponse { +public class LogoutResponse { private String message; } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/login/presentation/LoginController.java b/backend/yigil-api/src/main/java/kr/co/yigil/login/presentation/LoginController.java deleted file mode 100644 index 8b72b6fb7..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/login/presentation/LoginController.java +++ /dev/null @@ -1,38 +0,0 @@ -package kr.co.yigil.login.presentation; - -import static kr.co.yigil.login.util.LoginUtils.extractToken; - -import jakarta.servlet.http.HttpSession; -import kr.co.yigil.login.application.LoginStrategyManager; -import kr.co.yigil.login.application.strategy.LoginStrategy; -import kr.co.yigil.login.dto.request.LoginRequest; -import kr.co.yigil.login.dto.response.LoginResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class LoginController { - - private final LoginStrategyManager loginStrategyManager; - - @PostMapping("/api/v1/login/{provider}") - public ResponseEntity login( - @PathVariable("provider") final String provider, - @RequestHeader(value = "Authorization") String authorizationHeader, - @RequestBody LoginRequest loginRequest, - HttpSession session - ) { - String accessToken = extractToken(authorizationHeader); - LoginStrategy strategy = loginStrategyManager.getLoginStrategy(provider); - LoginResponse response = strategy.login(loginRequest, accessToken, session); - return ResponseEntity.ok(response); - } - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/application/MemberFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/application/MemberFacade.java new file mode 100644 index 000000000..f2d56c141 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/application/MemberFacade.java @@ -0,0 +1,35 @@ +package kr.co.yigil.member.application; + +import org.springframework.stereotype.Service; + +import kr.co.yigil.member.domain.MemberCommand; +import kr.co.yigil.member.domain.MemberInfo; +import kr.co.yigil.member.domain.MemberService; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberFacade { + + private final MemberService memberService; + + public MemberInfo.Main getMemberInfo(final Long memberId) { + return memberService.retrieveMemberInfo(memberId); + } + + public MemberInfo.MemberUpdateResponse updateMemberInfo(final Long memberId, + MemberCommand.MemberUpdateRequest request) { + + memberService.updateMemberInfo(memberId, request); + return new MemberInfo.MemberUpdateResponse("회원 정보 업데이트 성공"); + } + + public MemberInfo.MemberDeleteResponse withdraw(final Long memberId) { + memberService.withdrawal(memberId); + return new MemberInfo.MemberDeleteResponse("회원 탈퇴 성공"); + } + + public MemberInfo.NicknameCheckInfo nicknameDuplicateCheck(String nickname) { + return memberService.nicknameDuplicateCheck(nickname); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/application/MemberService.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/application/MemberService.java deleted file mode 100644 index 86515f023..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/application/MemberService.java +++ /dev/null @@ -1,94 +0,0 @@ -package kr.co.yigil.member.application; - -import static kr.co.yigil.global.exception.ExceptionCode.NOT_FOUND_MEMBER_ID; - -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; -import kr.co.yigil.file.FileUploadEvent; -import kr.co.yigil.follow.application.FollowRedisIntegrityService; -import kr.co.yigil.follow.domain.Follow; -import kr.co.yigil.follow.domain.FollowCount; -import kr.co.yigil.follow.domain.repository.FollowCountRepository; -import kr.co.yigil.follow.domain.repository.FollowRepository; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.repository.MemberRepository; -import kr.co.yigil.member.dto.request.MemberUpdateRequest; -import kr.co.yigil.member.dto.response.MemberDeleteResponse; -import kr.co.yigil.member.dto.response.MemberFollowerListResponse; -import kr.co.yigil.member.dto.response.MemberFollowingListResponse; -import kr.co.yigil.member.dto.response.MemberInfoResponse; -import kr.co.yigil.member.dto.response.MemberUpdateResponse; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.post.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MemberService { - - private final MemberRepository memberRepository; - private final PostRepository postRepository; - private final FollowRepository followRepository; - private final FollowCountRepository followCountRepository; - private final FollowRedisIntegrityService followRedisIntegrityService; - private final ApplicationEventPublisher applicationEventPublisher; - - - public MemberInfoResponse getMemberInfo(final Long memberId) { - Member member = findMemberById(memberId); - List postList = postRepository.findAllByMember(member); - FollowCount followCount = getMemberFollowCount(member); - return MemberInfoResponse.from(member, postList, followCount); - } - - private FollowCount getMemberFollowCount(Member member) { - return followRedisIntegrityService.ensureFollowCounts(member); - } - - public MemberUpdateResponse updateMemberInfo(final Long memberId, MemberUpdateRequest request) { - Member member = findMemberById(memberId); - - FileUploadEvent event = new FileUploadEvent(this, request.getProfileImageFile(), fileUrl -> { - Member updateMember = setMemberInfoUpdated(member, fileUrl, request.getNickname()); - memberRepository.save(updateMember); - - }); - applicationEventPublisher.publishEvent(event); - - return new MemberUpdateResponse("회원 정보 업데이트 성공"); - } - - private Member setMemberInfoUpdated(Member member, String fileUrl, String nickname) { - return new Member(member.getId(), member.getEmail(), member.getSocialLoginId(), - nickname, fileUrl, member.getSocialLoginType()); - } - - public MemberDeleteResponse withdraw(final Long memberId) { - Member member = findMemberById(memberId); - memberRepository.delete(member); - return new MemberDeleteResponse("회원 탈퇴 성공"); - } - - public MemberFollowerListResponse getFollowerList(final Long memberId) { - Member member = findMemberById(memberId); - List followers = followRepository.findAllByFollowing(member) - .stream().map(Follow::getFollower).collect(Collectors.toList()); - return new MemberFollowerListResponse(followers); - } - - public MemberFollowingListResponse getFollowingList(final Long memberId) { - Member member = findMemberById(memberId); - List followings = followRepository.findAllByFollower(member) - .stream().map(Follow::getFollowing).collect(Collectors.toList()); - return new MemberFollowingListResponse(followings); - } - - public Member findMemberById(Long memberId){ - return memberRepository.findById(memberId) - .orElseThrow(() -> new BadRequestException(NOT_FOUND_MEMBER_ID)); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/Member.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/Member.java deleted file mode 100644 index 0660aa874..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/Member.java +++ /dev/null @@ -1,94 +0,0 @@ -package kr.co.yigil.member.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import java.time.LocalDateTime; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.Where; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(uniqueConstraints = { - @UniqueConstraint(columnNames = {"socialLoginId", "socialLoginType"}) -}) -@SQLDelete(sql = "UPDATE member SET status = 'WITHDRAW' WHERE id = ?") -@Where(clause = "status = 'ACTIVE'") -public class Member { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, length = 50, unique = true) - private String email; - - @Column(nullable = false, length = 30) - private String socialLoginId; - - @Column(nullable = false, length = 20) - private String nickname; - - @Column(columnDefinition = "TEXT") - private String profileImageUrl; - - @Enumerated(value = EnumType.STRING) - private MemberStatus status; - - @Enumerated(value = EnumType.STRING) - private SocialLoginType socialLoginType; - - @CreatedDate - @Column(updatable = false) - private LocalDateTime joinedAt; - - @LastModifiedDate - private LocalDateTime modifiedAt; - - public Member(final String email, final String socialLoginId, final String nickname, final String profileImageUrl, final String socialLoginTypeString) { - this.email = email; - this.socialLoginId = socialLoginId; - this.nickname = nickname; - this.profileImageUrl = profileImageUrl; - this.status = MemberStatus.ACTIVE; - this.socialLoginType = SocialLoginType.valueOf(socialLoginTypeString.toUpperCase()); - this.joinedAt = LocalDateTime.now(); - this.modifiedAt = LocalDateTime.now(); - } - - public Member(final String email, final String socialLoginId, final String nickname, final String profileImageUrl, final SocialLoginType socialLoginType) { - this.email = email; - this.socialLoginId = socialLoginId; - this.nickname = nickname; - this.profileImageUrl = profileImageUrl; - this.status = MemberStatus.ACTIVE; - this.socialLoginType = socialLoginType; - this.joinedAt = LocalDateTime.now(); - this.modifiedAt = LocalDateTime.now(); - } - - public Member(final Long id, final String email, final String socialLoginId, final String nickname, final String profileImageUrl, final SocialLoginType socialLoginType) { - this.id = id; - this.email = email; - this.socialLoginId = socialLoginId; - this.nickname = nickname; - this.profileImageUrl = profileImageUrl; - this.status = MemberStatus.ACTIVE; - this.socialLoginType = socialLoginType; - this.joinedAt = LocalDateTime.now(); - this.modifiedAt = LocalDateTime.now(); - } - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberCommand.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberCommand.java new file mode 100644 index 000000000..11a2a48a8 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberCommand.java @@ -0,0 +1,23 @@ +package kr.co.yigil.member.domain; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.web.multipart.MultipartFile; + +public class MemberCommand { + + @Getter + @Builder + @ToString + public static class MemberUpdateRequest { + + private String nickname; + private String ages; + private String gender; + private MultipartFile profileImageFile; + private List favoriteRegionIds; + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberInfo.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberInfo.java new file mode 100644 index 000000000..1de06bf23 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberInfo.java @@ -0,0 +1,97 @@ +package kr.co.yigil.member.domain; + +import java.util.List; +import kr.co.yigil.follow.domain.FollowCount; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.region.domain.Region; +import lombok.Getter; +import lombok.ToString; + +public class MemberInfo { + /** + * 멤버 정보 조회 응답 + */ + @Getter + @ToString + public static class Main { + + private final Long memberId; + private final String email; + private final String nickname; + private final String profileImageUrl; + private final List favoriteRegions; + private final int followingCount; + private final int followerCount; + + public Main(Member member, FollowCount followCount) { + this.memberId = member.getId(); + this.email = member.getEmail(); + this.nickname = member.getNickname(); + this.profileImageUrl = member.getProfileImageUrl(); + this.followingCount = followCount.getFollowingCount(); + this.followerCount = followCount.getFollowerCount(); + this.favoriteRegions = member.getFavoriteRegions().stream(). + map(MemberInfo.FavoriteRegionInfo::new) + .toList(); + } + } + + @Getter + public static class FavoriteRegionInfo { + + private final Long id; + private final String name; + + public FavoriteRegionInfo(Region region) { + this.id = region.getId(); + this.name = region.getName1() + " " + region.getName2(); + } + } + + @Getter + @ToString + public static class PlaceInfo { + + private final String placeName; + private final String placeAddress; + private final String mapStaticImageUrl; + private final String placeImageUrl; + + public PlaceInfo(Place place) { + this.placeName = place.getName(); + this.placeAddress = place.getAddress(); + this.mapStaticImageUrl = place.getMapStaticImageFile().getFileUrl(); + this.placeImageUrl = place.getImageFile().getFileUrl(); + } + } + + @Getter + @ToString + public static class MemberUpdateResponse { + private final String message; + + public MemberUpdateResponse(String message) { + this.message = message; + } + } + + + @Getter + @ToString + public static class MemberDeleteResponse { + private final String message; + public MemberDeleteResponse(String message) { + this.message = message; + } + } + + @Getter + public static class NicknameCheckInfo { + private final boolean available; + + public NicknameCheckInfo(boolean isAvailable) { + this.available = isAvailable; + } + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberReader.java new file mode 100644 index 000000000..3fc3e7224 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberReader.java @@ -0,0 +1,18 @@ +package kr.co.yigil.member.domain; + +import java.util.Optional; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; + +public interface MemberReader { + + Member getMember(Long memberId); + + Optional findMemberBySocialLoginIdAndSocialLoginType(String socialLoginId, SocialLoginType socialLoginType); + + Optional findMemberByEmailAndSocialLoginType(String email, SocialLoginType socialLoginType); + + void validateMember(Long memberId); + + boolean existsByNickname(String nickname); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberService.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberService.java new file mode 100644 index 000000000..09403adc5 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberService.java @@ -0,0 +1,15 @@ +package kr.co.yigil.member.domain; + +import org.springframework.stereotype.Component; + +@Component +public interface MemberService { + + MemberInfo.Main retrieveMemberInfo(Long memberId); + + void withdrawal(Long memberId); + + void updateMemberInfo(Long memberId, MemberCommand.MemberUpdateRequest request); + + MemberInfo.NicknameCheckInfo nicknameDuplicateCheck(String nickname); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberServiceImpl.java new file mode 100644 index 000000000..b97a8261d --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberServiceImpl.java @@ -0,0 +1,73 @@ +package kr.co.yigil.member.domain; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.FileReader; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.follow.domain.FollowReader; +import kr.co.yigil.member.domain.MemberCommand.MemberUpdateRequest; +import kr.co.yigil.member.domain.MemberInfo.Main; +import kr.co.yigil.region.domain.MemberRegion; +import kr.co.yigil.region.domain.RegionReader; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberServiceImpl implements MemberService { + + private final MemberReader memberReader; + private final MemberStore memberStore; + private final FollowReader followReader; + private final FileUploader fileUploader; + private final RegionReader regionReader; + private final FileReader fileReader; + + @Override + @Transactional + public Main retrieveMemberInfo(final Long memberId) { + var member = memberReader.getMember(memberId); + var followCount = followReader.getFollowCount(memberId); + return new Main(member, followCount); + } + + @Override + @Transactional + public void withdrawal(final Long memberId) { + memberStore.deleteMember(memberId); + } + + @Override + @Transactional + public void updateMemberInfo(final Long memberId, final MemberCommand.MemberUpdateRequest request) { + + var member = memberReader.getMember(memberId); + AttachFile updatedProfile = getAttachFile(request); + + var memberRegions = regionReader.getRegions(request.getFavoriteRegionIds()) + .stream().map(region -> new MemberRegion(member, region)) + .toList(); + + member.updateMemberInfo(request.getNickname(), request.getAges(), request.getGender(), + updatedProfile, memberRegions); + + } + + @Override + public MemberInfo.NicknameCheckInfo nicknameDuplicateCheck(String nickname) { + + if (memberReader.existsByNickname(nickname.trim())) { + return new MemberInfo.NicknameCheckInfo(false); + } + return new MemberInfo.NicknameCheckInfo(true); + } + + private AttachFile getAttachFile(final MemberUpdateRequest request) { + + if (request.getProfileImageFile() != null) { + return fileUploader.upload(request.getProfileImageFile()); + } + return null; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberStore.java new file mode 100644 index 000000000..82f25250a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/MemberStore.java @@ -0,0 +1,10 @@ +package kr.co.yigil.member.domain; + +import kr.co.yigil.member.Member; + +public interface MemberStore { + + public void deleteMember(Long memberId); + + public Member save(Member member); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/repository/MemberRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/repository/MemberRepository.java deleted file mode 100644 index 3c0705e12..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/domain/repository/MemberRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package kr.co.yigil.member.domain.repository; - -import java.util.Optional; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -public interface MemberRepository extends JpaRepository { - - Optional findMemberBySocialLoginIdAndSocialLoginType(String socialLoginId, SocialLoginType type); -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberFollowerListResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberFollowerListResponse.java deleted file mode 100644 index f4c353364..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberFollowerListResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package kr.co.yigil.member.dto.response; - -import java.util.List; -import kr.co.yigil.member.domain.Member; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class MemberFollowerListResponse { - - private List followerList; -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberFollowingListResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberFollowingListResponse.java deleted file mode 100644 index 53c22d3ac..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberFollowingListResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package kr.co.yigil.member.dto.response; - -import java.util.List; -import kr.co.yigil.member.domain.Member; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class MemberFollowingListResponse { - - private List followingList; -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberInfoResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberInfoResponse.java deleted file mode 100644 index e163909e4..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberInfoResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -package kr.co.yigil.member.dto.response; - -import java.util.List; -import kr.co.yigil.follow.domain.FollowCount; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class MemberInfoResponse { - - private String nickname; - - private String profileImageUrl; - - private List postList; - - private int followerCount; - - private int followingCount; - - public static MemberInfoResponse from(final Member member, final List postList, final - FollowCount followCount) { - return new MemberInfoResponse(member.getNickname(), member.getProfileImageUrl(), postList, followCount.getFollowerCount(), - followCount.getFollowingCount()); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberUpdateResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberUpdateResponse.java deleted file mode 100644 index f2bad64e4..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberUpdateResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package kr.co.yigil.member.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class MemberUpdateResponse { - private String message; -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/infrastructure/MemberReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/infrastructure/MemberReaderImpl.java new file mode 100644 index 000000000..466104a3a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/infrastructure/MemberReaderImpl.java @@ -0,0 +1,48 @@ +package kr.co.yigil.member.infrastructure; + +import static kr.co.yigil.global.exception.ExceptionCode.*; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class MemberReaderImpl implements MemberReader { + private final MemberRepository memberRepository; + @Override + public Member getMember(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() ->new BadRequestException(NOT_FOUND_MEMBER_ID)); + } + + @Override + public Optional findMemberBySocialLoginIdAndSocialLoginType(String socialLoginId, + SocialLoginType socialLoginType) { + return memberRepository.findMemberBySocialLoginIdAndSocialLoginType(socialLoginId, socialLoginType); + } + + @Override + public Optional findMemberByEmailAndSocialLoginType(String email, + SocialLoginType socialLoginType) { + return memberRepository.findMemberByEmailAndSocialLoginType(email, socialLoginType); + } + + public void validateMember(Long memberId) { + if(!memberRepository.existsById(memberId)){ + throw new BadRequestException(NOT_FOUND_MEMBER_ID); + } + } + + @Override + public boolean existsByNickname(String nickname) { + return memberRepository.existsByNickname(nickname); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/infrastructure/MemberStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/infrastructure/MemberStoreImpl.java new file mode 100644 index 000000000..d9f67896c --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/infrastructure/MemberStoreImpl.java @@ -0,0 +1,24 @@ +package kr.co.yigil.member.infrastructure; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberStore; +import kr.co.yigil.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberStoreImpl implements MemberStore { + + private final MemberRepository memberRepository; + + @Override + public void deleteMember(Long memberId) { + memberRepository.deleteById(memberId); + } + + @Override + public Member save(Member member) { + return memberRepository.save(member); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/interfaces/controller/MemberApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/interfaces/controller/MemberApiController.java new file mode 100644 index 000000000..1f87b4dcf --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/interfaces/controller/MemberApiController.java @@ -0,0 +1,77 @@ +package kr.co.yigil.member.interfaces.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.member.application.MemberFacade; +import kr.co.yigil.member.domain.MemberInfo; +import kr.co.yigil.member.interfaces.dto.MemberDto; +import kr.co.yigil.member.interfaces.dto.mapper.MemberDtoMapper; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/members") +public class MemberApiController { + + private final MemberFacade memberFacade; + private final MemberDtoMapper memberDtoMapper; + + @GetMapping + @MemberOnly + public ResponseEntity getMyInfo(@Auth final Accessor accessor) { + var memberInfo = memberFacade.getMemberInfo(accessor.getMemberId()); + var response = memberDtoMapper.of(memberInfo); + return ResponseEntity.ok().body(response); + } + + @PostMapping + @MemberOnly + public ResponseEntity updateMyInfo( + @Auth final Accessor accessor, + @ModelAttribute MemberDto.MemberUpdateRequest request + ) { + var memberCommand = memberDtoMapper.of(request); + var message = memberFacade.updateMemberInfo(accessor.getMemberId(), + memberCommand); + var response = memberDtoMapper.of(message); + return ResponseEntity.ok().body(response); + } + + @DeleteMapping + @MemberOnly + public ResponseEntity withdraw(HttpServletRequest request, + @Auth final Accessor accessor) { + var responseInfo = memberFacade.withdraw(accessor.getMemberId()); + var response = memberDtoMapper.of(responseInfo); + request.getSession().invalidate(); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/{memberId}") + public ResponseEntity getMemberInfo( + @PathVariable("memberId") final Long memberId) { + var memberInfo = memberFacade.getMemberInfo(memberId); + var response = memberDtoMapper.of(memberInfo); + return ResponseEntity.ok(response); + } + + @PostMapping("/nickname_duplicate_check") + public ResponseEntity nicknameDuplicateCheck( + @RequestBody MemberDto.NicknameCheckRequest request) { + MemberInfo.NicknameCheckInfo checkInfo = memberFacade.nicknameDuplicateCheck(request.getNickname()); + MemberDto.NicknameCheckResponse response = memberDtoMapper.of(checkInfo); + return ResponseEntity.ok().body(response); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/interfaces/dto/MemberDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/interfaces/dto/MemberDto.java new file mode 100644 index 000000000..d205324ae --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/interfaces/dto/MemberDto.java @@ -0,0 +1,82 @@ +package kr.co.yigil.member.interfaces.dto; + +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +public class MemberDto { + + @Getter + @Setter + @ToString + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class MemberUpdateRequest { + + private String nickname; + private String ages; + private String gender; + private MultipartFile profileImageFile; + private List favoriteRegionIds; + } + + @Getter + @Builder + @ToString + public static class Main { + + private final Long memberId; + private final String email; + private final String nickname; + private final String profileImageUrl; + private final List favoriteRegions; + private final int followingCount; + private final int followerCount; + } + + @Getter + @Builder + public static class FavoriteRegion { + + private final Long id; + private final String name; + } + + @Getter + @Builder + @ToString + public static class MemberUpdateResponse { + + private final String message; + } + + @Getter + @Builder + @ToString + public static class MemberDeleteResponse { + private final String message; + } + + @Getter + @Builder + public static class NicknameCheckResponse { + private final boolean available; + } + + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class NicknameCheckRequest { + private String nickname; + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/interfaces/dto/mapper/MemberDtoMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/interfaces/dto/mapper/MemberDtoMapper.java new file mode 100644 index 000000000..430edf2ca --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/member/interfaces/dto/mapper/MemberDtoMapper.java @@ -0,0 +1,28 @@ +package kr.co.yigil.member.interfaces.dto.mapper; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import kr.co.yigil.member.domain.MemberCommand; +import kr.co.yigil.member.domain.MemberInfo; +import kr.co.yigil.member.interfaces.dto.MemberDto; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface MemberDtoMapper { + + @Mapping(target="favoriteRegionIds", source="favoriteRegionIds") + MemberCommand.MemberUpdateRequest of(MemberDto.MemberUpdateRequest request); + + MemberDto.Main of(MemberInfo.Main main); + MemberDto.MemberUpdateResponse of(MemberInfo.MemberUpdateResponse response); + MemberDto.MemberDeleteResponse of(MemberInfo.MemberDeleteResponse response); + + // @Mapping(target="available", source="available") + MemberDto.NicknameCheckResponse of(MemberInfo.NicknameCheckInfo response); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/presentation/MemberController.java b/backend/yigil-api/src/main/java/kr/co/yigil/member/presentation/MemberController.java deleted file mode 100644 index f003bae82..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/presentation/MemberController.java +++ /dev/null @@ -1,70 +0,0 @@ -package kr.co.yigil.member.presentation; - -import jakarta.servlet.http.HttpServletRequest; -import kr.co.yigil.auth.Auth; -import kr.co.yigil.auth.MemberOnly; -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.member.application.MemberService; -import kr.co.yigil.member.dto.request.MemberUpdateRequest; -import kr.co.yigil.member.dto.response.MemberDeleteResponse; -import kr.co.yigil.member.dto.response.MemberFollowerListResponse; -import kr.co.yigil.member.dto.response.MemberFollowingListResponse; -import kr.co.yigil.member.dto.response.MemberInfoResponse; -import kr.co.yigil.member.dto.response.MemberUpdateResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class MemberController { - - private final MemberService memberService; - - @GetMapping("/api/v1/member") - @MemberOnly - public ResponseEntity getMyInfo(@Auth final Accessor accessor) { - final MemberInfoResponse response = memberService.getMemberInfo(accessor.getMemberId()); - return ResponseEntity.ok().body(response); - } - - @PostMapping("/api/v1/member") - @MemberOnly - public ResponseEntity updateMyInfo(@Auth final Accessor accessor, @ModelAttribute - MemberUpdateRequest request) { - final MemberUpdateResponse response = memberService.updateMemberInfo(accessor.getMemberId(), request); - return ResponseEntity.ok().body(response); - } - - @DeleteMapping("/api/v1/member") - @MemberOnly - public ResponseEntity withdraw(HttpServletRequest request, @Auth final Accessor accessor) { - final MemberDeleteResponse response = memberService.withdraw(accessor.getMemberId()); - request.getSession().invalidate(); - return ResponseEntity.ok().body(response); - } - - - @GetMapping("/api/v1/member/{memberId}") - public ResponseEntity getMemberInfo(@PathVariable("memberId") final Long memberId) { - MemberInfoResponse response = memberService.getMemberInfo(memberId); - return ResponseEntity.ok(response); - } - - @GetMapping("/api/v1/member/follower/{memberId}") - public ResponseEntity getMemberFollowerList(@PathVariable("memberId") final Long memberId) { - MemberFollowerListResponse response = memberService.getFollowerList(memberId); - return ResponseEntity.ok(response); - } - - @GetMapping("/api/v1/member/following/{memberId}") - public ResponseEntity getMemberFollowingList(@PathVariable("memberId") final Long memberId) { - MemberFollowingListResponse response = memberService.getFollowingList(memberId); - return ResponseEntity.ok(response); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/application/NotificationFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/application/NotificationFacade.java new file mode 100644 index 000000000..741920666 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/application/NotificationFacade.java @@ -0,0 +1,24 @@ +package kr.co.yigil.notification.application; + +import kr.co.yigil.notification.domain.Notification; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import kr.co.yigil.notification.domain.NotificationService; + +@Service +@RequiredArgsConstructor +public class NotificationFacade { + private final NotificationService notificationService; + + public Flux> getNotificationStream(Long memberId) { + return notificationService.getNotificationStream(memberId); + } + + public Slice getNotificationSlice(Long memberId, PageRequest pageRequest) { + return notificationService.getNotificationSlice(memberId, pageRequest); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationFactory.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationFactory.java new file mode 100644 index 000000000..d280242cb --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationFactory.java @@ -0,0 +1,15 @@ +package kr.co.yigil.notification.domain; + +import kr.co.yigil.member.Member; +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.domain.NotificationType; +import org.springframework.stereotype.Component; + +@Component +public class NotificationFactory { + public Notification createNotification(NotificationType notificationType, Member sender, Member receiver) { + String message = notificationType.composeMessage(sender.getNickname(), receiver.getNickname()); + return new Notification(receiver, message); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationReader.java new file mode 100644 index 000000000..558f578b4 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationReader.java @@ -0,0 +1,13 @@ +package kr.co.yigil.notification.domain; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.codec.ServerSentEvent; +import reactor.core.publisher.Flux; + +public interface NotificationReader { + + Flux> getNotificationStream(Long memberId); + + Slice getNotificationSlice(Long memberId, Pageable pageable); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationSender.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationSender.java new file mode 100644 index 000000000..c531a2d35 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationSender.java @@ -0,0 +1,7 @@ +package kr.co.yigil.notification.domain; + +public interface NotificationSender { + + void sendNotification(NotificationType notificationType, Long senderId, + Long receiverId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationService.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationService.java new file mode 100644 index 000000000..923d8f595 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationService.java @@ -0,0 +1,16 @@ +package kr.co.yigil.notification.domain; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; + +public interface NotificationService { + + Flux> getNotificationStream(Long memberId); + + void sendNotification(NotificationType notificationType, Long senderId, Long receiverId); + + Slice getNotificationSlice(Long memberId, PageRequest pageRequest); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationServiceImpl.java new file mode 100644 index 000000000..13da5e443 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationServiceImpl.java @@ -0,0 +1,34 @@ +package kr.co.yigil.notification.domain; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; + +@Service +@RequiredArgsConstructor +public class NotificationServiceImpl implements NotificationService{ + private final NotificationReader notificationReader; + private final NotificationSender notificationSender; + @Transactional(readOnly = true) + @Override + public Flux> getNotificationStream(Long memberId) { + return notificationReader.getNotificationStream(memberId); + } + + @Transactional + @Override + public void sendNotification(NotificationType notificationType, Long senderId, Long receiverId) { + notificationSender.sendNotification(notificationType, senderId, receiverId); + } + + @Transactional(readOnly = true) + @Override + public Slice getNotificationSlice(Long memberId, PageRequest pageRequest) { + return notificationReader.getNotificationSlice(memberId, pageRequest); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationStore.java new file mode 100644 index 000000000..177d65f6c --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationStore.java @@ -0,0 +1,6 @@ +package kr.co.yigil.notification.domain; + +public interface NotificationStore { + + void store(Notification notification); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationType.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationType.java new file mode 100644 index 000000000..4fc8534ec --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/NotificationType.java @@ -0,0 +1,21 @@ +package kr.co.yigil.notification.domain; + +import java.util.function.BinaryOperator; + +public enum NotificationType { + FOLLOW((sender, receiver) -> sender + "님이 팔로우 하였습니다."), + UNFOLLOW((sender, receiver) -> sender + "님이 언팔로우 하였습니다."), + FAVOR((sender, receiver) -> sender + "님이 게시글에 좋아요를 눌렀습니다."), + NEW_COMMENT((sender, receiver) -> sender + "님이 게시글에 댓글을 달았습니다."), + UPDATE_COMMENT((sender, receiver) -> sender + "님이 댓글을 수정하였습니다."),; + + private final BinaryOperator messageComposer; + + NotificationType(BinaryOperator messageComposer) { + this.messageComposer = messageComposer; + } + + public String composeMessage(String sender, String receiver) { + return messageComposer.apply(sender, receiver); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/repository/NotificationRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/repository/NotificationRepository.java deleted file mode 100644 index ac9cb9b0c..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/notification/domain/repository/NotificationRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.co.yigil.notification.domain.repository; - -import kr.co.yigil.notification.domain.Notification; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface NotificationRepository extends JpaRepository { - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/infrastructure/NotificationReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/infrastructure/NotificationReaderImpl.java new file mode 100644 index 000000000..855ff4c84 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/infrastructure/NotificationReaderImpl.java @@ -0,0 +1,32 @@ +package kr.co.yigil.notification.infrastructure; + +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.domain.NotificationReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; + +@Component +@RequiredArgsConstructor +public class NotificationReaderImpl implements NotificationReader { + private final Sinks.Many sink = Sinks.many().multicast().onBackpressureBuffer(); + private final NotificationRepository notificationRepository; + + @Override + public Flux> getNotificationStream(Long memberId) { + return sink.asFlux() + .filter(notification -> notification.getMember().getId().equals(memberId)) + .map(notification -> ServerSentEvent.builder() + .data(notification) + .build()); + } + + @Override + public Slice getNotificationSlice(Long memberId, Pageable pageable) { + return notificationRepository.findAllByMemberId(memberId, pageable); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/infrastructure/NotificationSenderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/infrastructure/NotificationSenderImpl.java new file mode 100644 index 000000000..f42520109 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/infrastructure/NotificationSenderImpl.java @@ -0,0 +1,33 @@ +package kr.co.yigil.notification.infrastructure; + +import kr.co.yigil.notification.domain.NotificationFactory; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.domain.NotificationSender; +import kr.co.yigil.notification.domain.NotificationStore; +import kr.co.yigil.notification.domain.NotificationType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Sinks; +@Component +@RequiredArgsConstructor +public class NotificationSenderImpl implements NotificationSender { + private final MemberReader memberReader; + private final Sinks.Many sink = Sinks.many().multicast().onBackpressureBuffer(); + private final NotificationStore notificationStore; + private final NotificationFactory notificationFactory; + @Override + public void sendNotification(NotificationType notificationType, Long senderId, + Long receiverId) { + Member sender = memberReader.getMember(senderId); + Member receiver = memberReader.getMember(receiverId); + Notification notification = notificationFactory.createNotification(notificationType, sender, receiver); + notificationStore.store(notification); + sendRealTimeNotification(notification); + } + private void sendRealTimeNotification(Notification notification) { + sink.tryEmitNext(notification); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/infrastructure/NotificationStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/infrastructure/NotificationStoreImpl.java new file mode 100644 index 000000000..807ada31c --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/infrastructure/NotificationStoreImpl.java @@ -0,0 +1,18 @@ +package kr.co.yigil.notification.infrastructure; + +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.domain.NotificationStore; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NotificationStoreImpl implements NotificationStore { + private final NotificationRepository notificationRepository; + + @Override + public void store(Notification notification) { + notificationRepository.save(notification); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/controller/NotificationApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/controller/NotificationApiController.java new file mode 100644 index 000000000..22bfb03de --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/controller/NotificationApiController.java @@ -0,0 +1,59 @@ +package kr.co.yigil.notification.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import kr.co.yigil.notification.application.NotificationFacade; +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.interfaces.dto.mapper.NotificationMapper; +import kr.co.yigil.notification.interfaces.dto.response.NotificationsResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + +@RestController +@RequiredArgsConstructor +public class NotificationApiController { + + private final NotificationFacade notificationFacade; + private final NotificationMapper notificationMapper; + + @GetMapping(path = "/api/v1/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @MemberOnly + public Flux> streamNotifications(@Auth Accessor accessor) { + return notificationFacade.getNotificationStream(accessor.getMemberId()); + } + + @GetMapping("/api/v1/notifications") + @MemberOnly + public ResponseEntity getNotifications( + @Auth Accessor accessor, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + Slice notificationSlice = notificationFacade.getNotificationSlice( + accessor.getMemberId(), pageRequest); + NotificationsResponse response = notificationMapper.notificationSliceToNotificationsResponse( + notificationSlice); + return ResponseEntity.ok().body(response); + } + + // todo : add notification read api +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/dto/NotificationInfoDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/dto/NotificationInfoDto.java new file mode 100644 index 000000000..26a8f77dd --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/dto/NotificationInfoDto.java @@ -0,0 +1,15 @@ +package kr.co.yigil.notification.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class NotificationInfoDto { + + private String message; + private String createDate; + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/dto/mapper/NotificationMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/dto/mapper/NotificationMapper.java new file mode 100644 index 000000000..5ecece9b4 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/dto/mapper/NotificationMapper.java @@ -0,0 +1,30 @@ +package kr.co.yigil.notification.interfaces.dto.mapper; + +import java.util.List; +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.interfaces.dto.NotificationInfoDto; +import kr.co.yigil.notification.interfaces.dto.response.NotificationsResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; +import org.springframework.data.domain.Slice; + +@Mapper(componentModel = "spring") +public interface NotificationMapper { + + default NotificationsResponse notificationSliceToNotificationsResponse(Slice notificationSlice) { + List notificationInfoDtoList = notificationsToNotificationInfoDtoList(notificationSlice.getContent()); + boolean hasNext = notificationSlice.hasNext(); + return new NotificationsResponse(notificationInfoDtoList, hasNext); + } + + default List notificationsToNotificationInfoDtoList(List notifications) { + return notifications.stream() + .map(this::notificationToNotificationInfoDto) + .toList(); + } + + @Mapping(target = "message", source = "message") + @Mapping(target = "createDate", expression = "java(notification.getCreatedAt().toString())") + NotificationInfoDto notificationToNotificationInfoDto(Notification notification); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/dto/response/NotificationsResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/dto/response/NotificationsResponse.java new file mode 100644 index 000000000..d70211d43 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/notification/interfaces/dto/response/NotificationsResponse.java @@ -0,0 +1,15 @@ +package kr.co.yigil.notification.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.notification.interfaces.dto.NotificationInfoDto; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class NotificationsResponse { + private List notifications; + private boolean hasNext; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/notification/presentation/NotificationController.java b/backend/yigil-api/src/main/java/kr/co/yigil/notification/presentation/NotificationController.java deleted file mode 100644 index 40031d077..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/notification/presentation/NotificationController.java +++ /dev/null @@ -1,26 +0,0 @@ -package kr.co.yigil.notification.presentation; - -import kr.co.yigil.auth.Auth; -import kr.co.yigil.auth.MemberOnly; -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.notification.application.NotificationService; -import kr.co.yigil.notification.domain.Notification; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.http.codec.ServerSentEvent; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; -import reactor.core.publisher.Flux; - -@RestController -@RequiredArgsConstructor -public class NotificationController { - - private final NotificationService notificationService; - - @GetMapping(path = "/api/v1/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - @MemberOnly - public Flux> streamNotifications(@Auth Accessor accessor) { - return notificationService.getNotificationStream(accessor.getMemberId()); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/application/PlaceFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/application/PlaceFacade.java new file mode 100644 index 000000000..e2bdbf2b2 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/application/PlaceFacade.java @@ -0,0 +1,66 @@ +package kr.co.yigil.place.application; + +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCommand.NearPlaceRequest; +import kr.co.yigil.place.domain.PlaceInfo; +import kr.co.yigil.place.domain.PlaceInfo.Keyword; +import kr.co.yigil.place.domain.PlaceInfo.Main; +import kr.co.yigil.place.domain.PlaceInfo.MapStaticImageInfo; +import kr.co.yigil.place.domain.PlaceService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PlaceFacade { + private final PlaceService placeService; + + public MapStaticImageInfo findPlaceStaticImage(final String placeName, final String address) { + return placeService.findPlaceStaticImage(placeName, address); + } + + public List
getPopularPlace(final Accessor accessor) { + return placeService.getPopularPlace(accessor); + } + + public List
getPopularPlaceMore(final Accessor accessor) { + return placeService.getPopularPlaceMore(accessor); + } + + public PlaceInfo.Detail retrievePlaceInfo(final Long placeId, final Accessor accessor) { + return placeService.retrievePlace(placeId, accessor); + } + + public List
getPlaceInRegion(final Long regionId, final Accessor accessor) { + return placeService.getPlaceInRegion(regionId, accessor); + } + + public List
getPlaceInRegionMore(final Long regionId, final Accessor accessor) { + return placeService.getPlaceInRegionMore(regionId, accessor); + } + + public Page getNearPlace(final NearPlaceRequest command) { + return placeService.getNearPlace(command); + } + + public List
getPopularPlaceByDemographics(final Long memberId) { + return placeService.getPopularPlaceByDemographics(memberId); + } + + public List
getPopularPlaceByDemographicsMore(final Long memberId) { + return placeService.getPopularPlaceByDemographicsMore(memberId); + } + + public Slice
searchPlace(final String keyword, final Pageable pageable, final Accessor accessor) { + return placeService.searchPlace(keyword, pageable, accessor); + } + public List getPlaceKeywords(final String keyword) { + return placeService.getPlaceKeywords(keyword); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceCacheReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceCacheReader.java new file mode 100644 index 000000000..d7a0020ce --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceCacheReader.java @@ -0,0 +1,6 @@ +package kr.co.yigil.place.domain; + +public interface PlaceCacheReader { + int getSpotCount(Long placeId); + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceCacheStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceCacheStore.java new file mode 100644 index 000000000..834233509 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceCacheStore.java @@ -0,0 +1,6 @@ +package kr.co.yigil.place.domain; + +public interface PlaceCacheStore { + int incrementSpotCountInPlace(Long placeId); + int decrementSpotCountInPlace(Long placeId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceCommand.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceCommand.java new file mode 100644 index 000000000..8320fdb3c --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceCommand.java @@ -0,0 +1,25 @@ +package kr.co.yigil.place.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +public class PlaceCommand { + + @Getter + @Builder + @ToString + public static class NearPlaceRequest { + private final Coordinate minCoordinate; + private final Coordinate maxCoordinate; + private final int pageNo; + } + + @Getter + @Builder + @ToString + public static class Coordinate { + private final double x; + private final double y; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceInfo.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceInfo.java new file mode 100644 index 000000000..26e9389c3 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceInfo.java @@ -0,0 +1,98 @@ +package kr.co.yigil.place.domain; + +import java.util.Optional; +import lombok.Getter; +import lombok.ToString; + +public class PlaceInfo { + + @Getter + @ToString + public static class Main { + private final Long id; + private final String name; + private final int reviewCount; + private final String thumbnailImageUrl; + private final double rate; + private final boolean isBookmarked; + + public Main(Place place, int spotCount) { + id = place.getId(); + name = place.getName(); + reviewCount = spotCount; + thumbnailImageUrl = place.getImageFileUrl(); + rate = place.getRate(); + isBookmarked = false; + } + public Main(Place place, int spotCount, boolean isBookmarked) { + id = place.getId(); + name = place.getName(); + reviewCount = spotCount; + thumbnailImageUrl = place.getImageFileUrl(); + rate = place.getRate(); + this.isBookmarked = isBookmarked; + } + } + + @Getter + @ToString + public static class Detail { + private final Long id; + private final String name; + private final String address; + private final String thumbnailImageUrl; + private final String mapStaticImageUrl; + private final boolean isBookmarked; + private final double rate; + private final int reviewCount; + + public Detail(Place place, int spotCount) { + id = place.getId(); + name = place.getName(); + address = place.getAddress(); + thumbnailImageUrl = place.getImageFileUrl(); + mapStaticImageUrl = place.getMapStaticImageFileUrl(); + rate = place.getRate(); + reviewCount = spotCount; + isBookmarked = false; + } + public Detail(Place place, int spotCount, boolean isBookmarked) { + id = place.getId(); + name = place.getName(); + address = place.getAddress(); + thumbnailImageUrl = place.getImageFileUrl(); + mapStaticImageUrl = place.getMapStaticImageFileUrl(); + rate = place.getRate(); + reviewCount = spotCount; + this.isBookmarked = isBookmarked; + } + } + + @Getter + @ToString + public static class MapStaticImageInfo { + private final String imageUrl; + private final boolean exists; + + public MapStaticImageInfo(Optional placeOptional) { + if(placeOptional.isEmpty()) { + exists = false; + imageUrl = ""; + } else { + exists = true; + imageUrl = placeOptional.get() + .getMapStaticImageFileUrl(); + } + } + } + + @Getter + @ToString + public static class Keyword { + private final String keyword; + + public Keyword(String keyword) { + this.keyword = keyword; + } + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceReader.java new file mode 100644 index 000000000..6dcf6b026 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceReader.java @@ -0,0 +1,32 @@ +package kr.co.yigil.place.domain; + +import java.util.List; +import java.util.Optional; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface PlaceReader { + + Optional findPlaceByNameAndAddress(String placeName, String placeAddress); + + Place getPlace(Long placeId); + + List getPopularPlace(); + + List getPlaceInRegion(Long regionId); + + List getPlaceInRegionMore(Long regionId); + + Page getNearPlace(PlaceCommand.NearPlaceRequest command); + + List getPlaceKeywords(String keyword); + + List getPopularPlaceByDemographics(Ages ages, Gender gender); + + List getPopularPlaceByDemographicsMore(Ages ages, Gender gender); + + Slice getPlacesByKeyword(String keyword, Pageable pageable); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceService.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceService.java new file mode 100644 index 000000000..7a4589a8c --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceService.java @@ -0,0 +1,26 @@ +package kr.co.yigil.place.domain; + +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.place.domain.PlaceInfo.Keyword; +import kr.co.yigil.place.domain.PlaceInfo.Main; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface PlaceService { + public List
getPopularPlace(Accessor accessor); + public List
getPopularPlaceMore(Accessor accessor); + public List
getPlaceInRegion(Long regionId, Accessor accessor); + public List
getPlaceInRegionMore(Long regionId, Accessor accessor); + public PlaceInfo.Detail retrievePlace(Long placeId, Accessor accessor); + public PlaceInfo.MapStaticImageInfo findPlaceStaticImage(String placeName, String address); + public Page getNearPlace(PlaceCommand.NearPlaceRequest command); + public List getPlaceKeywords(String keyword); + + List
getPopularPlaceByDemographics(Long memberId); + + List
getPopularPlaceByDemographicsMore(Long memberId); + + Slice
searchPlace(String keyword, Pageable pageable, Accessor accessor); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceServiceImpl.java new file mode 100644 index 000000000..2d312c59b --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceServiceImpl.java @@ -0,0 +1,173 @@ +package kr.co.yigil.place.domain; + +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.bookmark.domain.BookmarkReader; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.place.domain.PlaceCommand.NearPlaceRequest; +import kr.co.yigil.place.domain.PlaceInfo.Detail; +import kr.co.yigil.place.domain.PlaceInfo.Keyword; +import kr.co.yigil.place.domain.PlaceInfo.Main; +import kr.co.yigil.place.domain.PlaceInfo.MapStaticImageInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PlaceServiceImpl implements PlaceService { + + private final PlaceReader placeReader; + private final PopularPlaceReader popularPlaceReader; + private final PlaceCacheReader placeCacheReader; + private final BookmarkReader bookmarkReader; + private final MemberReader memberReader; + + @Override + @Transactional(readOnly = true) + public List
getPopularPlace(final Accessor accessor) { + return popularPlaceReader.getPopularPlace().stream() + .map(place -> { + int spotCount = placeCacheReader.getSpotCount(place.getId()); + if (accessor.isMember()) { + boolean isBookmarked = bookmarkReader.isBookmarked(accessor.getMemberId(), + place.getId()); + return new Main(place, spotCount, isBookmarked); + } + return new Main(place, spotCount); + }) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List
getPopularPlaceMore(final Accessor accessor) { + return popularPlaceReader.getPopularPlaceMore().stream() + .map(place -> { + int spotCount = placeCacheReader.getSpotCount(place.getId()); + if (accessor.isMember()) { + boolean isBookmarked = bookmarkReader.isBookmarked(accessor.getMemberId(), place.getId()); + return new Main(place, spotCount, isBookmarked); + } + return new Main(place, spotCount); + }) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List
getPlaceInRegion(final Long regionId, final Accessor accessor) { + return placeReader.getPlaceInRegion(regionId).stream() + .map(place -> { + int spotCount = placeCacheReader.getSpotCount(place.getId()); + if (accessor.isMember()) { + boolean isBookmarked = bookmarkReader.isBookmarked(accessor.getMemberId(), place.getId()); + return new Main(place, spotCount, isBookmarked); + } + return new Main(place, spotCount); + }) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List
getPlaceInRegionMore(Long regionId, Accessor accessor) { + return placeReader.getPlaceInRegionMore(regionId).stream() + .map(place -> { + int spotCount = placeCacheReader.getSpotCount(place.getId()); + if (accessor.isMember()) { + boolean isBookmarked = bookmarkReader.isBookmarked(accessor.getMemberId(), place.getId()); + return new Main(place, spotCount, isBookmarked); + } + return new Main(place, spotCount); + }) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public Detail retrievePlace(final Long placeId, final Accessor accessor) { + var place = placeReader.getPlace(placeId); + int spotCount = placeCacheReader.getSpotCount(placeId); + return accessor.isMember() + ? new Detail(place, spotCount, bookmarkReader.isBookmarked(accessor.getMemberId(), placeId)) + : new Detail(place, spotCount); + } + + @Override + @Transactional(readOnly = true) + public MapStaticImageInfo findPlaceStaticImage(final String placeName, final String address) { + var placeOptional = placeReader.findPlaceByNameAndAddress(placeName, address); + return new MapStaticImageInfo(placeOptional); + } + + @Override + @Transactional(readOnly = true) + public Page getNearPlace(final NearPlaceRequest command) { + return placeReader.getNearPlace(command); + } + + @Override + public List getPlaceKeywords(String keywords) { + return placeReader.getPlaceKeywords(keywords).stream() + .map(Keyword::new) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List
getPopularPlaceByDemographics(final Long memberId) { + var member = memberReader.getMember(memberId); + Ages ages = member.getAges(); + Gender gender = member.getGender(); + + return placeReader.getPopularPlaceByDemographics(ages, gender).stream() + .map(place -> { + int spotCount = placeCacheReader.getSpotCount(place.getId()); + boolean isBookmarked = bookmarkReader.isBookmarked(memberId, place.getId()); + return new Main(place, spotCount, isBookmarked); + + }) + .collect(Collectors.toList()); + + } + + @Override + @Transactional(readOnly = true) + public List
getPopularPlaceByDemographicsMore(final Long memberId) { + var member = memberReader.getMember(memberId); + Ages ages = member.getAges(); + Gender gender = member.getGender(); + + return placeReader.getPopularPlaceByDemographicsMore(ages, gender).stream() + .map(place -> { + int spotCount = placeCacheReader.getSpotCount(place.getId()); + boolean isBookmarked = bookmarkReader.isBookmarked(memberId, place.getId()); + return new Main(place, spotCount, isBookmarked); + + }) + .collect(Collectors.toList()); + + } + + @Override + @Transactional(readOnly = true) + public Slice
searchPlace(String keyword, Pageable pageable, Accessor accessor) { + return placeReader.getPlacesByKeyword(keyword, pageable).map( + place -> { + int spotCount = placeCacheReader.getSpotCount(place.getId()); + if (accessor.isMember()) { + boolean isBookmarked = bookmarkReader.isBookmarked(accessor.getMemberId(), place.getId()); + return new Main(place, spotCount, isBookmarked); + } + return new Main(place, spotCount); + } + ); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceStore.java new file mode 100644 index 000000000..831669ca3 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PlaceStore.java @@ -0,0 +1,5 @@ +package kr.co.yigil.place.domain; + +public interface PlaceStore { + Place store(Place initPlace); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PopularPlaceReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PopularPlaceReader.java new file mode 100644 index 000000000..df5376f52 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/domain/PopularPlaceReader.java @@ -0,0 +1,9 @@ +package kr.co.yigil.place.domain; + +import java.util.List; + +public interface PopularPlaceReader { + List getPopularPlace(); + + List getPopularPlaceMore(); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceCacheReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceCacheReaderImpl.java new file mode 100644 index 000000000..e5aa01578 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceCacheReaderImpl.java @@ -0,0 +1,19 @@ +package kr.co.yigil.place.infrastructure; + +import kr.co.yigil.place.domain.PlaceCacheReader; +import kr.co.yigil.travel.domain.spot.SpotReader; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PlaceCacheReaderImpl implements PlaceCacheReader { + private final SpotReader spotReader; + + @Override + @Cacheable(value = "spotCount") + public int getSpotCount(Long placeId) { + return spotReader.getSpotCountInPlace(placeId); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceCacheStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceCacheStoreImpl.java new file mode 100644 index 000000000..508a25718 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceCacheStoreImpl.java @@ -0,0 +1,28 @@ +package kr.co.yigil.place.infrastructure; + +import kr.co.yigil.place.domain.PlaceCacheReader; +import kr.co.yigil.place.domain.PlaceCacheStore; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CachePut; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PlaceCacheStoreImpl implements PlaceCacheStore { + + private final PlaceCacheReader placeCacheReader; + + @Override + @CachePut(value = "spotCount") + public int incrementSpotCountInPlace(Long placeId) { + int spotCount = placeCacheReader.getSpotCount(placeId); + return ++spotCount; + } + + @Override + @CachePut(value = "spotCount") + public int decrementSpotCountInPlace(Long placeId) { + int spotCount = placeCacheReader.getSpotCount(placeId); + return --spotCount; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceReaderImpl.java new file mode 100644 index 000000000..9156e912a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceReaderImpl.java @@ -0,0 +1,91 @@ +package kr.co.yigil.place.infrastructure; + +import static kr.co.yigil.global.exception.ExceptionCode.NOT_FOUND_PLACE_ID; + +import java.util.List; +import java.util.Optional; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import kr.co.yigil.place.domain.DemographicPlace; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCommand.Coordinate; +import kr.co.yigil.place.domain.PlaceCommand.NearPlaceRequest; +import kr.co.yigil.place.domain.PlaceReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PlaceReaderImpl implements PlaceReader { + + private final PlaceRepository placeRepository; + private final DemographicPlaceRepository demographicPlaceRepository; + + @Override + public Optional findPlaceByNameAndAddress(String placeName, String placeAddress) { + return placeRepository.findByNameAndAddress(placeName, placeAddress); + } + + @Override + public Place getPlace(Long placeId) { + return placeRepository.findById(placeId) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_PLACE_ID)); + } + + @Override + public List getPopularPlace() { + return placeRepository.findTop5ByOrderByIdAsc(); + } + + @Override + public List getPlaceInRegion(Long regionId) { + return placeRepository.findTop5ByRegionIdOrderByIdDesc(regionId); + } + + @Override + public List getPlaceInRegionMore(Long regionId) { + return placeRepository.findTop20ByRegionIdOrderByIdDesc(regionId); + } + + @Override + public Page getNearPlace(NearPlaceRequest command) { + Coordinate maxCoordinate = command.getMaxCoordinate(); + Coordinate minCoordinate = command.getMinCoordinate(); + PageRequest pageRequest = PageRequest.of(command.getPageNo() - 1, 5, Sort.by("id").descending()); + return placeRepository.findWithinCoordinates( + minCoordinate.getX(), minCoordinate.getY(), + maxCoordinate.getX(), maxCoordinate.getY(), + pageRequest + ); + } + + @Override + public List getPlaceKeywords(String keyword) { + return placeRepository.findTop10ByNameStartingWith(keyword) + .stream().map(Place::getName).toList(); + } + + @Override + public List getPopularPlaceByDemographics(Ages ages, Gender gender) { + return demographicPlaceRepository.findTop5ByAgesAndGenderOrderByReferenceCountDesc(ages, gender) + .stream().map(DemographicPlace::getPlace).toList(); + } + + @Override + public List getPopularPlaceByDemographicsMore(Ages ages, Gender gender) { + return demographicPlaceRepository.findTop20ByAgesAndGenderOrderByReferenceCountDesc(ages, gender) + .stream().map(DemographicPlace::getPlace).toList(); + } + + @Override + public Slice getPlacesByKeyword(String keyword, Pageable pageable) { + return placeRepository.findByNameOrAddressContainingIgnoreCase(keyword, pageable); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceStoreImpl.java new file mode 100644 index 000000000..b306b28f9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PlaceStoreImpl.java @@ -0,0 +1,17 @@ +package kr.co.yigil.place.infrastructure; + +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceStore; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PlaceStoreImpl implements PlaceStore { + private final PlaceRepository placeRepository; + + @Override + public Place store(Place initPlace) { + return placeRepository.save(initPlace); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PopularPlaceReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PopularPlaceReaderImpl.java new file mode 100644 index 000000000..9794c46c6 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/infrastructure/PopularPlaceReaderImpl.java @@ -0,0 +1,31 @@ +package kr.co.yigil.place.infrastructure; + +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PopularPlace; +import kr.co.yigil.place.domain.PopularPlaceReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PopularPlaceReaderImpl implements PopularPlaceReader { + private final PopularPlaceRepository popularPlaceRepository; + + @Override + public List getPopularPlace() { + return popularPlaceRepository.findTop5ByOrderByReferenceCountDesc() + .stream() + .map(PopularPlace::getPlace) + .collect(Collectors.toList()); + } + + @Override + public List getPopularPlaceMore() { + return popularPlaceRepository.findTop20ByOrderByReferenceCountDesc() + .stream() + .map(PopularPlace::getPlace) + .collect(Collectors.toList()); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/controller/PlaceApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/controller/PlaceApiController.java new file mode 100644 index 000000000..fa16b34f5 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/controller/PlaceApiController.java @@ -0,0 +1,148 @@ +package kr.co.yigil.place.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import kr.co.yigil.place.application.PlaceFacade; +import kr.co.yigil.place.interfaces.dto.PlaceDetailInfoDto; +import kr.co.yigil.place.interfaces.dto.mapper.PlaceMapper; +import kr.co.yigil.place.interfaces.dto.request.NearPlaceRequest; +import kr.co.yigil.place.interfaces.dto.request.PlaceImageRequest; +import kr.co.yigil.place.interfaces.dto.response.NearPlaceResponse; +import kr.co.yigil.place.interfaces.dto.response.PlaceKeywordResponse; +import kr.co.yigil.place.interfaces.dto.response.PlaceStaticImageResponse; +import kr.co.yigil.place.interfaces.dto.response.PopularPlaceResponse; +import kr.co.yigil.place.interfaces.dto.response.RegionPlaceResponse; +import kr.co.yigil.place.interfaces.dto.response.PlaceSearchResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/places") +public class PlaceApiController { + private final PlaceFacade placeFacade; + private final PlaceMapper placeMapper; + @GetMapping("/static-image") + @MemberOnly + public ResponseEntity findPlaceStaticImage( + PlaceImageRequest request, + @Auth Accessor accessor + ) { + var placeInfo = placeFacade.findPlaceStaticImage(request.getName(), request.getAddress()); + var response = placeMapper.toPlaceStaticImageResponse(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/popular") + public ResponseEntity getPopularPlace( + @Auth Accessor accessor + ) { + var placeInfo = placeFacade.getPopularPlace(accessor); + var response = placeMapper.toPopularPlaceResponse(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/popular/more") + public ResponseEntity getPopularPlaceMore( + @Auth Accessor accessor + ) { + var placeInfo = placeFacade.getPopularPlaceMore(accessor); + var response = placeMapper.toPopularPlaceResponse(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/popular-demographics") + @MemberOnly + public ResponseEntity getPopularPlaceByDemographics( + @Auth Accessor accessor + ) { + var placeInfo = placeFacade.getPopularPlaceByDemographics(accessor.getMemberId()); + var response = placeMapper.toPopularPlaceResponse(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/popular-demographics-more") + @MemberOnly + public ResponseEntity getPopularPlaceByDemographicsMore( + @Auth Accessor accessor + ) { + var placeInfo = placeFacade.getPopularPlaceByDemographicsMore(accessor.getMemberId()); + var response = placeMapper.toPopularPlaceResponse(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/{placeId}") + public ResponseEntity retrievePlace( + @PathVariable("placeId") Long placeId, + @Auth Accessor accessor + ) { + var placeInfo = placeFacade.retrievePlaceInfo(placeId, accessor); + var response = placeMapper.toPlaceDetailInfoDto(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/region/{regionId}") + public ResponseEntity getRegionPlace( + @PathVariable("regionId") Long regionId, + @Auth Accessor accessor + ) { + var placeInfo = placeFacade.getPlaceInRegion(regionId, accessor); + var response = placeMapper.toRegionPlaceResponse(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/region/{regionId}/more") + public ResponseEntity getRegionPlaceMore( + @PathVariable("regionId") Long regionId, + @Auth Accessor accessor + ) { + var placeInfo = placeFacade.getPlaceInRegionMore(regionId, accessor); + var response = placeMapper.toRegionPlaceResponse(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/near") + public ResponseEntity getNearPlace(NearPlaceRequest request) { + var nearPlaceCommand = placeMapper.toNearPlaceCommand(request); + var placeInfo = placeFacade.getNearPlace(nearPlaceCommand); + var response = placeMapper.toNearPlaceResponse(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/search") + public ResponseEntity searchPlace( + @RequestParam(name = "keyword", required = false) String keyword, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "latest_uploaded_time", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder, + @Auth Accessor accessor + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + var placeInfo = placeFacade.searchPlace(keyword, pageRequest, accessor); + var response = placeMapper.toPlaceSearchResponse(placeInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/keyword") + public ResponseEntity getPlaceKeyword(@RequestParam String keyword) { + var keywordsInfo = placeFacade.getPlaceKeywords(keyword); + var response = placeMapper.toPlaceKeywordResponse(keywordsInfo); + return ResponseEntity.ok().body(response); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/PlaceCoordinateDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/PlaceCoordinateDto.java new file mode 100644 index 000000000..73a2ac6f1 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/PlaceCoordinateDto.java @@ -0,0 +1,13 @@ +package kr.co.yigil.place.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PlaceCoordinateDto { + private Long id; + private double x; + private double y; + private String placeName; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/PlaceDetailInfoDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/PlaceDetailInfoDto.java new file mode 100644 index 000000000..a396547ff --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/PlaceDetailInfoDto.java @@ -0,0 +1,17 @@ +package kr.co.yigil.place.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PlaceDetailInfoDto { + private Long id; + private String placeName; + private String address; + private String thumbnailImageUrl; + private String mapStaticImageUrl; + private boolean isBookmarked; + private double rate; + private int reviewCount; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/PlaceInfoDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/PlaceInfoDto.java new file mode 100644 index 000000000..84877f8fc --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/PlaceInfoDto.java @@ -0,0 +1,15 @@ +package kr.co.yigil.place.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PlaceInfoDto { + private Long id; + private String placeName; + private String reviewCount; + private String thumbnailImageUrl; + private String rate; + private boolean isBookmarked; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/mapper/PlaceMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/mapper/PlaceMapper.java new file mode 100644 index 000000000..eb4c27739 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/mapper/PlaceMapper.java @@ -0,0 +1,107 @@ +package kr.co.yigil.place.interfaces.dto.mapper; + +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCommand; +import kr.co.yigil.place.domain.PlaceInfo; +import kr.co.yigil.place.domain.PlaceInfo.Detail; +import kr.co.yigil.place.domain.PlaceInfo.Main; +import kr.co.yigil.place.domain.PlaceInfo.MapStaticImageInfo; +import kr.co.yigil.place.interfaces.dto.PlaceCoordinateDto; +import kr.co.yigil.place.interfaces.dto.PlaceDetailInfoDto; +import kr.co.yigil.place.interfaces.dto.PlaceInfoDto; +import kr.co.yigil.place.interfaces.dto.request.NearPlaceRequest; +import kr.co.yigil.place.interfaces.dto.response.NearPlaceResponse; +import kr.co.yigil.place.interfaces.dto.response.PlaceKeywordResponse; +import kr.co.yigil.place.interfaces.dto.response.PlaceSearchResponse; +import kr.co.yigil.place.interfaces.dto.response.PlaceStaticImageResponse; +import kr.co.yigil.place.interfaces.dto.response.PopularPlaceResponse; +import kr.co.yigil.place.interfaces.dto.response.RegionPlaceResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; +import org.mapstruct.factory.Mappers; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; + +@Mapper(componentModel = "spring") +public interface PlaceMapper { + + PlaceMapper INSTANCE = Mappers.getMapper(PlaceMapper.class); + + @Mappings({ + @Mapping(target = "mapStaticImageUrl", source = "imageUrl"), + @Mapping(target = "exists", source = "exists") + }) + PlaceStaticImageResponse toPlaceStaticImageResponse(MapStaticImageInfo info); + + + @Mappings({ + @Mapping(target = "id", source = "id"), + @Mapping(target = "placeName", source = "name"), + @Mapping(target = "reviewCount", expression = "java(String.valueOf(main.getReviewCount()))"), + @Mapping(target = "thumbnailImageUrl", source = "thumbnailImageUrl"), + @Mapping(target = "rate", expression = "java(String.format(\"%.1f\", main.getRate()))"), + @Mapping(target = "isBookmarked", source = "bookmarked") + }) + PlaceInfoDto mainToDto(PlaceInfo.Main main); + + default PopularPlaceResponse toPopularPlaceResponse(List
mains) { + List dtos = mains.stream().map(this::mainToDto).collect(Collectors.toList()); + return new PopularPlaceResponse(dtos); + } + + default RegionPlaceResponse toRegionPlaceResponse(List
mains) { + List dtos = mains.stream().map(this::mainToDto).collect(Collectors.toList()); + return new RegionPlaceResponse(dtos); + } + + @Mappings({ + @Mapping(target = "id", source = "id"), + @Mapping(target = "placeName", source = "name"), + @Mapping(target = "address", source = "address"), + @Mapping(target = "thumbnailImageUrl", source = "thumbnailImageUrl"), + @Mapping(target = "mapStaticImageUrl", source = "mapStaticImageUrl"), + @Mapping(target = "isBookmarked", source = "bookmarked"), + @Mapping(target = "rate", source = "rate"), + @Mapping(target = "reviewCount", source = "reviewCount") + }) + PlaceDetailInfoDto toPlaceDetailInfoDto(Detail detail); + + @Mapping(target = "minCoordinate.x", source = "minX") + @Mapping(target = "minCoordinate.y", source = "minY") + @Mapping(target = "maxCoordinate.x", source = "maxX") + @Mapping(target = "maxCoordinate.y", source = "maxY") + @Mapping(target = "pageNo", source = "page") + PlaceCommand.NearPlaceRequest toNearPlaceCommand(NearPlaceRequest nearPlaceRequest); + + @Mappings({ + @Mapping(target = "id", source = "id"), + @Mapping(target = "placeName", source = "name"), + @Mapping(target = "x", source = "location.x"), + @Mapping(target = "y", source = "location.y") + }) + PlaceCoordinateDto placeToPlaceCoordinateDto(Place place); + + default NearPlaceResponse toNearPlaceResponse(Page page) { + List placeCoordinateDtos = page.getContent().stream() + .map(this::placeToPlaceCoordinateDto) + .toList(); + + return new NearPlaceResponse(placeCoordinateDtos, page.getNumber() + 1, page.getTotalPages()); + } + + default PlaceSearchResponse toPlaceSearchResponse(Slice
placeInfo) { + List dtos = placeInfo.getContent().stream().map(this::mainToDto).collect(Collectors.toList()); + return new PlaceSearchResponse(dtos, placeInfo.hasNext()); + } + + default PlaceKeywordResponse toPlaceKeywordResponse(List keywords) { + List keywordStrings = keywords.stream() + .map(PlaceInfo.Keyword::getKeyword) + .collect(Collectors.toList()); + + return new PlaceKeywordResponse(keywordStrings); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/request/NearPlaceRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/request/NearPlaceRequest.java new file mode 100644 index 000000000..f3667f47d --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/request/NearPlaceRequest.java @@ -0,0 +1,14 @@ +package kr.co.yigil.place.interfaces.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class NearPlaceRequest { + private double minX; + private double minY; + private double maxX; + private double maxY; + private int page; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/request/PlaceImageRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/request/PlaceImageRequest.java new file mode 100644 index 000000000..460d181ac --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/request/PlaceImageRequest.java @@ -0,0 +1,11 @@ +package kr.co.yigil.place.interfaces.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PlaceImageRequest { + private String name; + private String address; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/NearPlaceResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/NearPlaceResponse.java new file mode 100644 index 000000000..6764cf67a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/NearPlaceResponse.java @@ -0,0 +1,14 @@ +package kr.co.yigil.place.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.place.interfaces.dto.PlaceCoordinateDto; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class NearPlaceResponse { + private List places; + private int currentPage; + private int totalPages; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PlaceKeywordResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PlaceKeywordResponse.java new file mode 100644 index 000000000..18de7e567 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PlaceKeywordResponse.java @@ -0,0 +1,11 @@ +package kr.co.yigil.place.interfaces.dto.response; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PlaceKeywordResponse { + private List keywords; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PlaceSearchResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PlaceSearchResponse.java new file mode 100644 index 000000000..227e3ea86 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PlaceSearchResponse.java @@ -0,0 +1,14 @@ +package kr.co.yigil.place.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.place.interfaces.dto.PlaceInfoDto; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PlaceSearchResponse { + private List places; + private boolean hasNext; + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PlaceStaticImageResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PlaceStaticImageResponse.java new file mode 100644 index 000000000..15451466a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PlaceStaticImageResponse.java @@ -0,0 +1,11 @@ +package kr.co.yigil.place.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PlaceStaticImageResponse { + private boolean exists; + private String mapStaticImageUrl; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PopularPlaceResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PopularPlaceResponse.java new file mode 100644 index 000000000..850bd74cf --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/PopularPlaceResponse.java @@ -0,0 +1,12 @@ +package kr.co.yigil.place.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.place.interfaces.dto.PlaceInfoDto; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PopularPlaceResponse { + private List places; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/RegionPlaceResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/RegionPlaceResponse.java new file mode 100644 index 000000000..7566e4fde --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/place/interfaces/dto/response/RegionPlaceResponse.java @@ -0,0 +1,12 @@ +package kr.co.yigil.place.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.place.interfaces.dto.PlaceInfoDto; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class RegionPlaceResponse { + private List places; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/post/application/PostService.java b/backend/yigil-api/src/main/java/kr/co/yigil/post/application/PostService.java deleted file mode 100644 index 0eb9c9464..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/post/application/PostService.java +++ /dev/null @@ -1,88 +0,0 @@ -package kr.co.yigil.post.application; - -import java.util.List; -import kr.co.yigil.comment.application.CommentRedisIntegrityService; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.post.domain.repository.PostRepository; -import kr.co.yigil.post.dto.response.PostDeleteResponse; -import kr.co.yigil.post.dto.response.PostListResponse; -import kr.co.yigil.post.dto.response.PostResponse; -import kr.co.yigil.travel.domain.Travel; -import kr.co.yigil.travel.domain.repository.TravelRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Service -public class PostService { - private final PostRepository postRepository; - private final TravelRepository travelRepository; - private final CommentRedisIntegrityService commentRedisIntegrityService; - - @Transactional(readOnly = true) - public PostListResponse findAllPosts() { // querydsl? 검색 쿼리 추가 - List posts = postRepository.findAll(); - List postResponses = posts.stream() - .map(post -> PostResponse.from(post.getTravel(), post, commentRedisIntegrityService.ensureCommentCount(post).getCommentCount())) - .toList(); - return PostListResponse.from(postResponses); - } - - @Transactional(readOnly = true) - public Post findPostById(Long postId) { - return postRepository.findById(postId).orElseThrow( - () -> new BadRequestException(ExceptionCode.NOT_FOUND_POST_ID) - ); - } - - @Transactional - public void createPost(Travel travel, Member member) { - postRepository.save(new Post(travel, member)); - } - - @Transactional - public void recreatePost(Long memberId, Long spotId) { - Post post = postRepository.findByMemberIdAndTravelIdAndIsDeleted(memberId, spotId, true) - .orElseThrow( - () -> new BadRequestException(ExceptionCode.NOT_FOUND_POST_ID) - ); - post.setIsDeleted(false); - } - - @Transactional - public void updatePost(Long postId, Travel travel, Member member) { - postRepository.save(new Post(postId, travel, member)); - } - - @Transactional - public PostDeleteResponse deletePost(Long memberId, Long postId) { - validatePostWriter(memberId, postId); - - Post post = findPostById(postId); - Travel travel = post.getTravel(); - travelRepository.delete(travel); - postRepository.delete(post); - return new PostDeleteResponse("post 삭제 성공"); - } - - @Transactional - public void deleteOnlyPost(Long memberId, Long travelId) { - Post post = postRepository.findByMemberIdAndTravelIdAndIsDeleted(memberId, travelId, false) - .orElseThrow( - () -> new BadRequestException(ExceptionCode.NOT_FOUND_POST_ID) - ); - post.setIsDeleted(true); - } - - @Transactional(readOnly = true) - public void validatePostWriter(Long memberId, Long postId) { - if (!postRepository.existsByMemberIdAndId(memberId, postId)) { - throw new BadRequestException(ExceptionCode.INVALID_AUTHORITY); - } - } - -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/post/domain/Post.java b/backend/yigil-api/src/main/java/kr/co/yigil/post/domain/Post.java deleted file mode 100644 index 4e8254eeb..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/post/domain/Post.java +++ /dev/null @@ -1,55 +0,0 @@ -package kr.co.yigil.post.domain; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToOne; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.travel.domain.Travel; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.Where; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SQLDelete(sql = "UPDATE post SET is_deleted = true WHERE id = ?") -@Where(clause = "is_deleted = false") -public class Post { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Setter - @OneToOne - @JoinColumn(name = "travel_id") - private Travel travel; - - @ManyToOne - @JoinColumn(name = "member_id") - private Member member; - - @Setter - private Boolean isDeleted = false; - - public Post(Travel travel, Member member) { - this.travel = travel; - this.member = member; - } - - public Post(Long id, Travel travel, Member member) { - this.id = id; - this.travel = travel; - this.member = member; - } -// public Post(Long id) { -// this.id = id; -// } - -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/post/domain/repository/PostRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/post/domain/repository/PostRepository.java deleted file mode 100644 index c0c260186..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/post/domain/repository/PostRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package kr.co.yigil.post.domain.repository; - -import java.util.List; -import java.util.Optional; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -public interface PostRepository extends JpaRepository { - - void deleteByMemberIdAndTravelId(Long memberId, Long travelId); - - @Query(value = "SELECT p.* FROM Post p WHERE p.member_id = :memberId AND p.travel_id = :travelId AND p.is_deleted = :isDeleted", nativeQuery = true) - Optional findByMemberIdAndTravelIdAndIsDeleted(Long memberId, Long travelId, boolean isDeleted); - - List findAllByMember(Member member); - - boolean existsByMemberIdAndId(Long memberId, Long postId); -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/post/dto/response/PostDeleteResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/post/dto/response/PostDeleteResponse.java deleted file mode 100644 index db8cc3707..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/post/dto/response/PostDeleteResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package kr.co.yigil.post.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -@Data -@AllArgsConstructor -@NoArgsConstructor -public class PostDeleteResponse { - private String message; -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/post/dto/response/PostListResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/post/dto/response/PostListResponse.java deleted file mode 100644 index 7cc86b9c4..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/post/dto/response/PostListResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package kr.co.yigil.post.dto.response; - -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class PostListResponse { - private List posts; - - public static PostListResponse from(List posts) { - return new PostListResponse(posts); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/post/dto/response/PostResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/post/dto/response/PostResponse.java deleted file mode 100644 index 0a19b38a2..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/post/dto/response/PostResponse.java +++ /dev/null @@ -1,79 +0,0 @@ -package kr.co.yigil.post.dto.response; - -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.travel.domain.Course; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.domain.Travel; -import lombok.Data; -import lombok.Setter; - -@Data -public class PostResponse { - private Long postId; - private Long travelId; - - private String title; - private String imageUrl; - private String description; - - private String memberNickname; - private String memberImageUrl; - -// private int likeCount; - private int commentCount; - - public static PostResponse from(Travel travel, Post post, int commentCount) { - if (travel instanceof Spot spot) { - return from(spot, post, commentCount); - } else if(travel instanceof Course course){ - return from(course, post, commentCount); - } else{ - throw new BadRequestException(ExceptionCode.TRAVEL_CASTING_ERROR); - } - } - - public static PostResponse from(Spot spot, Post post, int commentCount) { - return new PostResponse( - post.getId(), - spot.getId(), - spot.getTitle(), - spot.getFileUrl(), - spot.getDescription(), - post.getMember().getNickname(), - post.getMember().getProfileImageUrl(), - commentCount - ); - } - - public static PostResponse from(Course course, Post post, int commentCount) { - Spot representativeSpot = course.getSpots().get(course.getRepresentativeSpotOrder()); - String imgUrl = representativeSpot.getFileUrl(); - String description = representativeSpot.getDescription(); - - return new PostResponse( - post.getId(), - course.getId(), - course.getTitle(), - imgUrl, - description, - post.getMember().getNickname(), - post.getMember().getProfileImageUrl(), - commentCount - ); - } - - public PostResponse(Long postId, Long travelId, String title, String imageUrl, String description, String memberNickname, String memberImageUrl, int commentCount) { - this.postId = postId; - this.travelId = travelId; - this.title = title; - this.imageUrl = imageUrl; - this.description = description; - this.memberNickname = memberNickname; - this.memberImageUrl = memberImageUrl; - this.commentCount = commentCount; - } - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/post/presentation/PostController.java b/backend/yigil-api/src/main/java/kr/co/yigil/post/presentation/PostController.java deleted file mode 100644 index 601c057b3..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/post/presentation/PostController.java +++ /dev/null @@ -1,43 +0,0 @@ -package kr.co.yigil.post.presentation; - -import kr.co.yigil.auth.Auth; -import kr.co.yigil.auth.MemberOnly; -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.post.application.PostService; -import kr.co.yigil.post.dto.response.PostDeleteResponse; -import kr.co.yigil.post.dto.response.PostListResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/posts") -public class PostController { - private final PostService postService; - - @GetMapping - public ResponseEntity findAllPosts() { - PostListResponse posts = postService.findAllPosts(); - return ResponseEntity.ok().body(posts); - } - - @DeleteMapping("/{post_id}") - @MemberOnly - public ResponseEntity deletePost( - @PathVariable("post_id") Long postId, - @Auth final Accessor accessor - ) { - PostDeleteResponse postDeleteResponse = postService.deletePost(accessor.getMemberId(), postId); -// PostDeleteResponse postDeleteResponse = postService.deletePost(1L, postId); - return ResponseEntity.ok().body(postDeleteResponse); - } - // todo: spot list 리턴해주는 엔드포인트 정의 - - - -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/application/RegionFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/application/RegionFacade.java new file mode 100644 index 000000000..dfac1ec29 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/application/RegionFacade.java @@ -0,0 +1,23 @@ +package kr.co.yigil.region.application; + +import java.util.List; +import kr.co.yigil.region.domain.Region; +import kr.co.yigil.region.domain.RegionInfo.Category; +import kr.co.yigil.region.domain.RegionInfo.Main; +import kr.co.yigil.region.domain.RegionService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RegionFacade { + private final RegionService regionService; + + public List getRegionSelectList(Long memberId) { + return regionService.getAllRegionCategory(memberId); + } + + public List
getMyRegions(Long memberId) { + return regionService.getMyRegions(memberId); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionCategoryReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionCategoryReader.java new file mode 100644 index 000000000..c57b971b7 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionCategoryReader.java @@ -0,0 +1,7 @@ +package kr.co.yigil.region.domain; + +import java.util.List; + +public interface RegionCategoryReader { + List getAllRegionCategory(); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionInfo.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionInfo.java new file mode 100644 index 000000000..3506d8c84 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionInfo.java @@ -0,0 +1,51 @@ +package kr.co.yigil.region.domain; + +import io.micrometer.common.util.StringUtils; +import java.util.List; +import kr.co.yigil.member.Member; +import lombok.Getter; +import lombok.ToString; + +public class RegionInfo { + + @Getter + @ToString + public static class Main { + private final String name; + private final Long id; + + public Main(Region region) { + id = region.getId(); + name = StringUtils.isEmpty(region.getName2()) ? region.getName1() : region.getName1() + " | " + region.getName2(); + } + } + + @Getter + @ToString + public static class Category { + private final String name; + private final List items; + + public Category(RegionCategory category, Member member) { + name = category.getName(); + this.items = category.getRegions().stream() + .map(region -> new CategoryItem(region, member.isFavoriteRegion(region))) + .toList(); + } + } + + @Getter + @ToString + public static class CategoryItem { + private final Long id; + private final String name; + private final boolean selected; + + public CategoryItem(Region region, boolean selected) { + id = region.getId(); + name = StringUtils.isEmpty(region.getName2()) ? region.getName1() : region.getName1() + " | " + region.getName2(); + this.selected = selected; + } + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionReader.java new file mode 100644 index 000000000..e2f06cfe9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionReader.java @@ -0,0 +1,10 @@ +package kr.co.yigil.region.domain; + +import java.util.List; + +public interface RegionReader { + + void validateRegions(List regionIds); + + List getRegions(List favoriteRegionIds); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionService.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionService.java new file mode 100644 index 000000000..c266a29bc --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionService.java @@ -0,0 +1,10 @@ +package kr.co.yigil.region.domain; + +import java.util.List; +import kr.co.yigil.region.domain.RegionInfo.Category; +import kr.co.yigil.region.domain.RegionInfo.Main; + +public interface RegionService { + public List getAllRegionCategory(Long memberId); + public List
getMyRegions(Long memberId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionServiceImpl.java new file mode 100644 index 000000000..e0ed38aef --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/domain/RegionServiceImpl.java @@ -0,0 +1,32 @@ +package kr.co.yigil.region.domain; + +import java.util.List; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.region.domain.RegionInfo.Category; +import kr.co.yigil.region.domain.RegionInfo.Main; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RegionServiceImpl implements RegionService{ + private final RegionCategoryReader regionCategoryReader; + private final MemberReader memberReader; + @Override + @Transactional(readOnly = true) + public List getAllRegionCategory(Long memberId) { + var member = memberReader.getMember(memberId); + var categories = regionCategoryReader.getAllRegionCategory(); + return categories.stream().map(category -> new Category(category, member)).toList(); + } + + @Override + @Transactional(readOnly = true) + public List
getMyRegions(Long memberId) { + var member = memberReader.getMember(memberId); + return member.getFavoriteRegions().stream() + .map(Main::new) + .toList(); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/infrastructure/RegionCategoryReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/infrastructure/RegionCategoryReaderImpl.java new file mode 100644 index 000000000..fdeb16adf --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/infrastructure/RegionCategoryReaderImpl.java @@ -0,0 +1,18 @@ +package kr.co.yigil.region.infrastructure; + +import java.util.List; +import kr.co.yigil.region.domain.RegionCategory; +import kr.co.yigil.region.domain.RegionCategoryReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RegionCategoryReaderImpl implements RegionCategoryReader { + private final RegionCategoryRepository regionCategoryRepository; + + @Override + public List getAllRegionCategory() { + return regionCategoryRepository.findAll(); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/infrastructure/RegionReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/infrastructure/RegionReaderImpl.java new file mode 100644 index 000000000..4d6c76bf2 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/infrastructure/RegionReaderImpl.java @@ -0,0 +1,34 @@ +package kr.co.yigil.region.infrastructure; + +import java.util.List; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.region.domain.Region; +import kr.co.yigil.region.domain.RegionReader; +import kr.co.yigil.region.infrastructure.RegionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RegionReaderImpl implements RegionReader { + + private final RegionRepository regionRepository; + + public void validateRegions(List regionIds) { + regionIds.forEach(regionId -> { + if (!regionRepository.existsById(regionId)) { + throw new IllegalArgumentException("존재하지 않는 지역입니다."); + } + }); + } + + @Override + public List getRegions(List regionIds) { + return regionIds.stream().map(regionRepository::findById).map( + region -> region.orElseThrow( + () -> new BadRequestException(ExceptionCode.NOT_FOUND_REGION_ID) + ) + ).toList(); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/controller/RegionApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/controller/RegionApiController.java new file mode 100644 index 000000000..e59b5c8b2 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/controller/RegionApiController.java @@ -0,0 +1,40 @@ +package kr.co.yigil.region.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.region.application.RegionFacade; +import kr.co.yigil.region.interfaces.dto.mapper.RegionMapper; +import kr.co.yigil.region.interfaces.dto.response.MyRegionResponse; +import kr.co.yigil.region.interfaces.dto.response.RegionSelectResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/regions") +public class RegionApiController { + + private final RegionFacade regionFacade; + private final RegionMapper regionMapper; + @GetMapping("/select") + @MemberOnly + public ResponseEntity getRegionSelect(@Auth final Accessor accessor) { + Long memberId = accessor.getMemberId(); + var regionInfo = regionFacade.getRegionSelectList(memberId); + var response = regionMapper.toRegionSelectResponse(regionInfo); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/my") + @MemberOnly + public ResponseEntity getMyRegion(@Auth final Accessor accessor) { + Long memberId = accessor.getMemberId(); + var myRegionInfo = regionFacade.getMyRegions(memberId); + var response = regionMapper.toMyRegionResponse(myRegionInfo); + return ResponseEntity.ok().body(response); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/MyRegionDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/MyRegionDto.java new file mode 100644 index 000000000..b6f5cfa32 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/MyRegionDto.java @@ -0,0 +1,11 @@ +package kr.co.yigil.region.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class MyRegionDto { + private Long id; + private String name; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/RegionCategoryDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/RegionCategoryDto.java new file mode 100644 index 000000000..c6953eaca --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/RegionCategoryDto.java @@ -0,0 +1,12 @@ +package kr.co.yigil.region.interfaces.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class RegionCategoryDto { + private String categoryName; + private List regions; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/RegionDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/RegionDto.java new file mode 100644 index 000000000..6f52ae6e5 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/RegionDto.java @@ -0,0 +1,12 @@ +package kr.co.yigil.region.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class RegionDto { + private Long id; + private String regionName; + private boolean selected; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/mapper/RegionMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/mapper/RegionMapper.java new file mode 100644 index 000000000..16a2bd1b8 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/mapper/RegionMapper.java @@ -0,0 +1,43 @@ +package kr.co.yigil.region.interfaces.dto.mapper; + +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.region.domain.RegionInfo; +import kr.co.yigil.region.interfaces.dto.MyRegionDto; +import kr.co.yigil.region.interfaces.dto.RegionCategoryDto; +import kr.co.yigil.region.interfaces.dto.RegionDto; +import kr.co.yigil.region.interfaces.dto.response.MyRegionResponse; +import kr.co.yigil.region.interfaces.dto.response.RegionSelectResponse; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface RegionMapper { + + default RegionSelectResponse toRegionSelectResponse(List categories) { + List categoryDtos = categories.stream() + .map(this::toRegionCategoryDto) + .collect(Collectors.toList()); + return new RegionSelectResponse(categoryDtos); + } + + default RegionCategoryDto toRegionCategoryDto(RegionInfo.Category category) { + if (category == null) { + return null; + } + List regions = category.getItems().stream() + .map(item -> new RegionDto(item.getId(), item.getName(), item.isSelected())) + .collect(Collectors.toList()); + + return new RegionCategoryDto(category.getName(), regions); + } + + default MyRegionResponse toMyRegionResponse (List mains) { + if (mains == null) return null; + + List regions = mains.stream() + .map(main -> new MyRegionDto(main.getId(), main.getName())) + .collect(Collectors.toList()); + + return new MyRegionResponse(regions); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/response/MyRegionResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/response/MyRegionResponse.java new file mode 100644 index 000000000..5b7db4059 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/response/MyRegionResponse.java @@ -0,0 +1,12 @@ +package kr.co.yigil.region.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.region.interfaces.dto.MyRegionDto; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class MyRegionResponse { + List regions; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/response/RegionSelectResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/response/RegionSelectResponse.java new file mode 100644 index 000000000..7d93361bf --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/region/interfaces/dto/response/RegionSelectResponse.java @@ -0,0 +1,12 @@ +package kr.co.yigil.region.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.region.interfaces.dto.RegionCategoryDto; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class RegionSelectResponse { + private List categories; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/CourseFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/CourseFacade.java new file mode 100644 index 000000000..d6cc3c0ca --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/CourseFacade.java @@ -0,0 +1,49 @@ +package kr.co.yigil.travel.application; + +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.course.CourseCommand.ModifyCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequestWithSpotInfo; +import kr.co.yigil.travel.domain.course.CourseInfo; +import kr.co.yigil.travel.domain.course.CourseService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CourseFacade { + private final CourseService courseService; + + public Slice getCourseSliceInPlace(Long placeId, Pageable pageable) { + return courseService.getCoursesSliceInPlace(placeId, pageable); + } + + public void registerCourse(RegisterCourseRequest command, Long memberId) { + courseService.registerCourse(command, memberId); + } + + public void registerCourseWithoutSeries(RegisterCourseRequestWithSpotInfo command, Long memberId) { + courseService.registerCourseWithoutSeries(command, memberId); + } + + public CourseInfo.Main retrieveCourseInfo(Long courseId) { return courseService.retrieveCourseInfo(courseId); } + + public void modifyCourse(ModifyCourseRequest command, Long courseId, Long memberId) { + courseService.modifyCourse(command, courseId, memberId); + } + + public void deleteCourse(Long courseId, Long memberId) { courseService.deleteCourse(courseId, memberId); } + + public CourseInfo.MyCoursesResponse getMemberCoursesInfo(final Long memberId, + Pageable pageable, Selected selected) { + return courseService.retrieveCourseList(memberId, pageable, selected); + } + + public CourseInfo.Slice searchCourseByPlaceName(final String keyword, final Accessor accessor, final Pageable pageable) { + return courseService.searchCourseByPlaceName(keyword, accessor, pageable); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/CourseService.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/CourseService.java deleted file mode 100644 index 1164d068e..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/CourseService.java +++ /dev/null @@ -1,111 +0,0 @@ -package kr.co.yigil.travel.application; - -import java.util.List; -import kr.co.yigil.comment.application.CommentService; -import kr.co.yigil.comment.dto.response.CommentResponse; -import kr.co.yigil.member.application.MemberService; -import kr.co.yigil.post.application.PostService; -import kr.co.yigil.travel.dto.request.CourseCreateRequest; -import kr.co.yigil.travel.dto.request.CourseUpdateRequest; -import kr.co.yigil.travel.dto.response.CourseCreateResponse; -import kr.co.yigil.travel.dto.response.CourseFindResponse; -import kr.co.yigil.travel.domain.Course; -import kr.co.yigil.travel.domain.Travel; -import kr.co.yigil.travel.domain.repository.CourseRepository; -import kr.co.yigil.travel.dto.response.CourseUpdateResponse; -import kr.co.yigil.travel.dto.response.SpotFindResponse; -import org.springframework.stereotype.Service; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.domain.repository.TravelRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.transaction.annotation.Transactional; - - -@Service -@RequiredArgsConstructor -public class CourseService { - private final CourseRepository courseRepository; - private final TravelRepository travelRepository; - private final MemberService memberService; - private final PostService postService; - private final SpotService spotService; - private final CommentService commentService; - - @Transactional - public CourseCreateResponse createCourse(Long memberId, CourseCreateRequest courseCreateRequest) { - Member member = memberService.findMemberById(memberId); - List spotIdList = courseCreateRequest.getSpotIds(); - List spots = spotService.getSpotListFromSpotIds(spotIdList); - - // 코스를 저장 - Course course = CourseCreateRequest.toEntity(courseCreateRequest, spots); - courseRepository.save(course); - postService.createPost(course, member); - - //코스에 포함된 spot들 isIncourse 속성 true로 변경 - course.getSpots().forEach(spot -> spot.setIsInCourse(true)); - - // 코스에 포함된 스팟을 담은 포스트 삭제 - spotIdList.forEach(spotId -> postService.deleteOnlyPost(memberId, spotId)); - - return new CourseCreateResponse("경로 생성 성공"); - } - - @Transactional(readOnly = true) - public CourseFindResponse findCourse(Long postId) { - Post post = postService.findPostById(postId); - Course course = castTravelToCourse(post.getTravel()); - List spots = course.getSpots(); - - List comments = commentService.getCommentList(course.getId()); - return CourseFindResponse.from(post, course, spots, comments); - } - - @Transactional - public CourseUpdateResponse updateCourse(Long postId, Long memberId, CourseUpdateRequest courseUpdateRequest) { - Post post = postService.findPostById(postId); - postService.validatePostWriter(memberId, postId); - - Member member = memberService.findMemberById(memberId); - List spotIdList = courseUpdateRequest.getSpotIds(); - List spots = travelRepository.findAllById(spotIdList).stream().map(Spot.class::cast).toList(); - - // 코스에 스팟을 넣을 때 Post는 삭제하고 spot 정보는 업데이트. - for(Long id: courseUpdateRequest.getAddedSpotIds()){ - postService.deleteOnlyPost(memberId, id); - Spot spot = spotService.findSpotById(id); - spot.setIsInCourse(true); - } - - // 코스에 있던 spot을 뺄때 다시 post에 등록, post 필드의 deleted 가 true인 것을 false로 변경 - for(Long id: courseUpdateRequest.getRemovedSpotIds()){ - // spotId와 member Id로 post를 찾아서 해당 포스트의 deleted 필드를 false로 변경 - postService.recreatePost(memberId, id); - Spot spot = spotService.findSpotById(id); - spot.setIsInCourse(false); - } - - // 코스 정보 업데이트 - Course course = castTravelToCourse(post.getTravel()); - Long courseId = course.getId(); - - Course newCourse = CourseUpdateRequest.toEntity(courseId, courseUpdateRequest, spots); - Course updatedCourse = courseRepository.save(newCourse); - - postService.updatePost(postId, newCourse, member); - - return CourseUpdateResponse.from(member, updatedCourse, spots); - } - - private Course castTravelToCourse(Travel travel) { - if(travel instanceof Course course) { - return course; - }else { - throw new BadRequestException(ExceptionCode.NOT_FOUND_COURSE_ID); - } - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/SpotFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/SpotFacade.java new file mode 100644 index 000000000..cdcca7be1 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/SpotFacade.java @@ -0,0 +1,49 @@ +package kr.co.yigil.travel.application; + +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.domain.spot.SpotCommand.ModifySpotRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterSpotRequest; +import kr.co.yigil.travel.domain.spot.SpotInfo; +import kr.co.yigil.travel.domain.spot.SpotInfo.Main; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpot; +import kr.co.yigil.travel.domain.spot.SpotInfo.Slice; +import kr.co.yigil.travel.domain.spot.SpotService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SpotFacade { + private final SpotService spotService; + + public Slice getSpotSliceInPlace(final Long placeId, final Accessor accessor, final Pageable pageable) { + return spotService.getSpotSliceInPlace(placeId, accessor, pageable); + } + + public MySpot retrieveMySpotInfoInPlace(final Long placeId, final Long memberId) { + return spotService.retrieveMySpotInfoInPlace(placeId, memberId); + } + + public void registerSpot(final RegisterSpotRequest command, final Long memberId) { + spotService.registerSpot(command, memberId); + } + + public Main retrieveSpotInfo(final Long spotId) { + return spotService.retrieveSpotInfo(spotId); + } + + public void modifySpot(final ModifySpotRequest command, final Long spotId, final Long memberId) { + spotService.modifySpot(command, spotId, memberId); + } + + public void deleteSpot(final Long spotId, final Long memberId) { + spotService.deleteSpot(spotId, memberId); + } + + public SpotInfo.MySpotsResponse getMemberSpotsInfo(final Long memberId, + final Selected selected, final Pageable pageable) { + return spotService.retrieveSpotList(memberId, selected, pageable); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/SpotService.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/SpotService.java deleted file mode 100644 index 01dd7116f..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/SpotService.java +++ /dev/null @@ -1,115 +0,0 @@ -package kr.co.yigil.travel.application; - -import java.util.List; -import java.util.concurrent.CompletableFuture; -import kr.co.yigil.comment.application.CommentService; -import kr.co.yigil.comment.dto.response.CommentResponse; -import kr.co.yigil.file.FileUploadEvent; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; -import kr.co.yigil.member.application.MemberService; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.application.PostService; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.post.domain.repository.PostRepository; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.domain.Travel; -import kr.co.yigil.travel.domain.repository.SpotRepository; -import kr.co.yigil.travel.dto.request.SpotCreateRequest; -import kr.co.yigil.travel.dto.request.SpotUpdateRequest; -import kr.co.yigil.travel.dto.response.SpotCreateResponse; -import kr.co.yigil.travel.dto.response.SpotFindResponse; -import kr.co.yigil.travel.dto.response.SpotUpdateResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class SpotService { - private final SpotRepository spotRepository; - private final MemberService memberService; - private final PostService postService; - private final ApplicationEventPublisher applicationEventPublisher; - private final CommentService commentService; - - private final PostRepository postRepository; - - @Transactional - public SpotCreateResponse createSpot(Long memberId, SpotCreateRequest spotCreateRequest) { - Member member = memberService.findMemberById(memberId); - -// Spot spot = spotRepository.save(SpotCreateRequest.toEntity(spotCreateRequest, "fileUrl")); -// postService.createPost(spot, member); - - FileUploadEvent event = new FileUploadEvent(this, spotCreateRequest.getFile(), - fileUrl -> { - System.out.println("fileUrl = " + fileUrl); - Spot spot = spotRepository.save(SpotCreateRequest.toEntity(spotCreateRequest, fileUrl)); - postService.createPost(spot, member); - }); - applicationEventPublisher.publishEvent(event); - return new SpotCreateResponse("스팟 정보 생성 성공"); - } - - @Transactional(readOnly = true) - public SpotFindResponse findSpotByPostId(Long postId) { - Post post = postService.findPostById(postId); - Member member = post.getMember(); - Spot spot = castTravelToSpot(post.getTravel()); - - List comments = commentService.getCommentList(spot.getId()); - return SpotFindResponse.from(member, spot, comments); - } - - @Transactional - public SpotUpdateResponse updateSpot(Long memberId, Long postId, SpotUpdateRequest spotUpdateRequest) { - - Post post = postService.findPostById(postId); - postService.validatePostWriter(memberId, postId); - - CompletableFuture fileUploadResult = new CompletableFuture<>(); - FileUploadEvent event = new FileUploadEvent(this, spotUpdateRequest.getFile(),fileUrl -> { - fileUploadResult.complete(fileUrl); - } - ); - applicationEventPublisher.publishEvent(event); - - String fileUrl = fileUploadResult.join(); - // 기존 포스트의 spot 정보 - Spot spot = castTravelToSpot(post.getTravel()); - Long spotId = spot.getId(); - Spot newSpot = SpotUpdateRequest.toEntity(spotId, spotUpdateRequest, fileUrl); - Spot updatedSpot = spotRepository.save(newSpot); - - Member member = memberService.findMemberById(memberId); - postService.updatePost(postId, updatedSpot, member); - - return SpotUpdateResponse.from(member, updatedSpot); - } - - @Transactional(readOnly = true) - public Spot findSpotById(Long spotId){ - return spotRepository.findById(spotId).orElseThrow( - () -> new BadRequestException(ExceptionCode.NOT_FOUND_SPOT_ID) - ); - } - - @Transactional(readOnly = true) - public List getSpotListFromSpotIds(List spotIdList){ - return spotIdList.stream() - .map(this::findSpotById) - .toList(); - } - - private Spot castTravelToSpot(Travel travel){ - if(travel instanceof Spot spot){ - return spot; - }else{ - throw new BadRequestException(ExceptionCode.TRAVEL_CASTING_ERROR); - } - } -} - - diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/TravelFacade.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/TravelFacade.java new file mode 100644 index 000000000..d671996bd --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/TravelFacade.java @@ -0,0 +1,21 @@ +package kr.co.yigil.travel.application; + +import kr.co.yigil.travel.domain.TravelCommand; +import kr.co.yigil.travel.domain.TravelService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TravelFacade { + private final TravelService travelService; + + public void changeOnPublicTravel(Long travelId, Long memberId) { travelService.changeOnPublic(travelId, memberId);} + + public void changeOnPrivateTravel(Long travelId, Long memberId) { travelService.changeOnPrivate(travelId, memberId);} + + public void setTravelsVisibility(Long memberId, + TravelCommand.VisibilityChangeRequest memberCommand) { + travelService.setTravelsVisibility(memberId, memberCommand); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/TravelService.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/TravelService.java deleted file mode 100644 index 098eb5cb5..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/application/TravelService.java +++ /dev/null @@ -1,22 +0,0 @@ -package kr.co.yigil.travel.application; - -import static kr.co.yigil.global.exception.ExceptionCode.NOT_FOUND_MEMBER_ID; -import static kr.co.yigil.global.exception.ExceptionCode.NOT_FOUND_TRAVEL_ID; - -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.travel.domain.Travel; -import kr.co.yigil.travel.domain.repository.TravelRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class TravelService { - private final TravelRepository travelRepository; - - public Travel findTravelById(Long travelId) { - return travelRepository.findById(travelId) - .orElseThrow(() -> new BadRequestException(NOT_FOUND_TRAVEL_ID)); - } - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/Course.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/Course.java deleted file mode 100644 index b72d32222..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/Course.java +++ /dev/null @@ -1,48 +0,0 @@ -package kr.co.yigil.travel.domain; - -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToMany; -import jakarta.persistence.OrderColumn; -import java.util.List; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.locationtech.jts.geom.LineString; - -@Entity -@Getter -@DiscriminatorValue("COURSE") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Course extends Travel{ - @Column(columnDefinition = "geometry(LineString,4326)") - private LineString path; - - @OneToMany(cascade = CascadeType.ALL) - @JoinColumn(name = "course_id") - @OrderColumn(name = "spot_order") - private List spots; - - private int representativeSpotOrder; - - @Column(nullable = false, length = 20) - private String title; - - public Course(final LineString path, final List spots, final int representativeSpotOrder, final String title) { - this.path = path; - this.spots = spots; - this.representativeSpotOrder = representativeSpotOrder; - this.title = title; - } - - public Course(final Long id, final LineString path, final List spots, final int representativeSpotOrder, final String title) { - super.setId(id); - this.path = path; - this.spots = spots; - this.representativeSpotOrder = representativeSpotOrder; - this.title = title; - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/Spot.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/Spot.java deleted file mode 100644 index 96a09bfcf..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/Spot.java +++ /dev/null @@ -1,65 +0,0 @@ -package kr.co.yigil.travel.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.locationtech.jts.geom.Point; - -@Entity -@Getter -@DiscriminatorValue("SPOT") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Spot extends Travel{ - @Column(columnDefinition = "geometry(Point,4326)") - private Point location; - - private boolean isInCourse; - - @Column(nullable = false, length = 20) - private String title; - - @Column(columnDefinition = "TEXT") - private String description; - - @Column(columnDefinition = "TEXT") - private String fileUrl; - - public Spot(final Long id) { - super.setId(id); - } - - public Spot(final Long id,final Point location, final boolean isInCourse, final String title, final String description, final String fileUrl) { - super.setId(id); - this.location = location; - this.isInCourse = isInCourse; - this.title = title; - this.description = description; - this.fileUrl = fileUrl; - } - public Spot(final Point location, final boolean isInCourse, final String title, final String description, final String fileUrl) { - this.location = location; - this.isInCourse = isInCourse; - this.title = title; - this.description = description; - this.fileUrl = fileUrl; - } - - public Spot(final Point location, final String title, final String description, final String fileUrl) { - this.location = location; - this.isInCourse = false; - this.title = title; - this.description = description; - this.fileUrl = fileUrl; - } - - public void setIsInCourse(boolean isInCourse){ - this.isInCourse = isInCourse; - } - -// public void setId(Long id){ -// super.setId(id); -// } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/Travel.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/Travel.java deleted file mode 100644 index ac16db82e..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/Travel.java +++ /dev/null @@ -1,55 +0,0 @@ -package kr.co.yigil.travel.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.DiscriminatorColumn; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Inheritance; -import jakarta.persistence.InheritanceType; -import java.time.LocalDateTime; -import lombok.Getter; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.Where; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; - - -@Entity -@Inheritance(strategy = InheritanceType.JOINED) -@DiscriminatorColumn(name = "type") -@Getter -@SQLDelete(sql = "UPDATE Travel SET is_deleted = true WHERE id = ?") -@Where(clause = "is_deleted = false") -public class Travel { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - - @CreatedDate - @Column(updatable = false) - private LocalDateTime createdAt; - - @LastModifiedDate - private LocalDateTime modifiedAt; - - boolean isDeleted; - - protected Travel() { - createdAt = LocalDateTime.now(); - modifiedAt = LocalDateTime.now(); - } - - public Travel(final Long id) { - this.id = id; - createdAt = LocalDateTime.now(); - modifiedAt = LocalDateTime.now(); - } - - protected void setId(Long id) { - this.id = id; - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelCommand.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelCommand.java new file mode 100644 index 000000000..7d3ec067f --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelCommand.java @@ -0,0 +1,19 @@ +package kr.co.yigil.travel.domain; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +public class TravelCommand { + private TravelCommand(){ + } + + @Getter + @Builder + @ToString + public static class VisibilityChangeRequest { + private List travelIds; + private Boolean isPrivate; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelReader.java new file mode 100644 index 000000000..95e653d46 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelReader.java @@ -0,0 +1,8 @@ +package kr.co.yigil.travel.domain; + +import java.util.List; + +public interface TravelReader { + Travel getTravel(Long travelId); + public List getTravels(List travelIds); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelService.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelService.java new file mode 100644 index 000000000..67f844e44 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelService.java @@ -0,0 +1,10 @@ +package kr.co.yigil.travel.domain; + +public interface TravelService { + + void changeOnPublic(Long travelId, Long memberId); + + void changeOnPrivate(Long travelId, Long memberId); + + void setTravelsVisibility(Long memberId, TravelCommand.VisibilityChangeRequest command); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelServiceImpl.java new file mode 100644 index 000000000..5eeabf554 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/TravelServiceImpl.java @@ -0,0 +1,55 @@ +package kr.co.yigil.travel.domain; + +import java.util.List; +import java.util.Objects; +import kr.co.yigil.global.exception.AuthException; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TravelServiceImpl implements TravelService { + + private final TravelReader travelReader; + + @Override + @Transactional + public void changeOnPublic(Long travelId, Long memberId) { + Travel travel = travelReader.getTravel(travelId); + validateTravelOwner(travel, memberId); + travel.changeOnPublic(); + } + + @Override + @Transactional + public void changeOnPrivate(Long travelId, Long memberId) { + Travel travel = travelReader.getTravel(travelId); + validateTravelOwner(travel, memberId); + travel.changeOnPrivate(); + } + + @Override + @Transactional + public void setTravelsVisibility(Long memberId, + TravelCommand.VisibilityChangeRequest travelCommand) { + List travelIds = travelCommand.getTravelIds(); + + travelReader.getTravels(travelIds) + .forEach(travel -> { + validateTravelOwner(travel, memberId); + if (travel.isPrivate() == travelCommand.getIsPrivate()) { + throw new BadRequestException(ExceptionCode.INVALID_VISIBILITY_REQUEST); + } + if (travel.isPrivate()) travel.changeOnPublic(); + else travel.changeOnPrivate(); + }); + } + + + private void validateTravelOwner(Travel travel, Long memberId) { + if(!Objects.equals(travel.getMember().getId(), memberId)) throw new AuthException(ExceptionCode.INVALID_AUTHORITY); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseCommand.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseCommand.java new file mode 100644 index 000000000..359931cbc --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseCommand.java @@ -0,0 +1,86 @@ +package kr.co.yigil.travel.domain.course; + +import java.util.List; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.member.Member; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.spot.SpotCommand.ModifySpotRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterSpotRequest; +import kr.co.yigil.travel.util.GeojsonConverter; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.web.multipart.MultipartFile; + +public class CourseCommand { + + @Getter + @Builder + @ToString + public static class RegisterCourseRequest { + private String title; + private String description; + private double rate; + private boolean isPrivate; + private int representativeSpotOrder; + private String lineStringJson; + private MultipartFile mapStaticImageFile; + private List registerSpotRequests; + + public Course toEntity(List spots, Member member, AttachFile attachFile) { + return new Course( + member, + title, + description, + rate, + GeojsonConverter.convertToLineString(lineStringJson), + isPrivate, + spots, + representativeSpotOrder, + attachFile + ); + } + } + + @Getter + @Builder + @ToString + public static class RegisterCourseRequestWithSpotInfo { + private String title; + private String description; + private double rate; + private boolean isPrivate; + private int representativeSpotOrder; + private String lineStringJson; + private MultipartFile mapStaticImageFile; + private List spotIds; + + public Course toEntity(List spots, Member member, AttachFile attachFile) { + return new Course( + member, + title, + description, + rate, + GeojsonConverter.convertToLineString(lineStringJson), + isPrivate, + spots, + representativeSpotOrder, + attachFile + ); + } + } + + @Getter + @Builder + @ToString + @AllArgsConstructor + public static class ModifyCourseRequest { + private String description; + private double rate; + private List spotIdOrder; + private List modifySpotRequests; + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseInfo.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseInfo.java new file mode 100644 index 000000000..168853bde --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseInfo.java @@ -0,0 +1,129 @@ +package kr.co.yigil.travel.domain.course; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.Spot; +import lombok.Getter; +import lombok.ToString; + +public class CourseInfo { + + @Getter + @ToString + public static class Main { + private final String title; + private final double rate; + private final String mapStaticImageUrl; + private final String description; + private final List courseSpotList; + + public Main(Course course) { + this.title = course.getTitle(); + this.rate = course.getRate(); + this.mapStaticImageUrl = course.getMapStaticImageFileUrl(); + this.description = course.getDescription(); + AtomicInteger index = new AtomicInteger(1); + this.courseSpotList = course.getSpots().stream() + .map(spot -> new CourseSpotInfo(spot, index.getAndIncrement())) + .collect(Collectors.toList()); + } + } + + @Getter + @ToString + public static class CourseSpotInfo { + private final int order; + private final String placeName; + private final List imageUrlList; + private final double rate; + private final String description; + private final LocalDateTime createDate; + + public CourseSpotInfo(Spot spot, int index) { + this.order = index; + this.placeName = spot.getPlace().getName(); + this.imageUrlList = spot.getAttachFiles().getUrls(); + this.rate = spot.getRate(); + this.description = spot.getDescription(); + this.createDate = spot.getCreatedAt(); + } + } + + @Getter + @ToString + public static class MyCoursesResponse { + + private final List content; + private final int totalPages; + public MyCoursesResponse(List courseList, int totalPages) { + this.content = courseList; + this.totalPages = totalPages; + } + } + + @Getter + @ToString + public static class CourseListInfo { + + private final Long courseId; + private final String title; + private final Double rate; + private final Integer spotCount; + private final LocalDateTime createdDate; + private final String mapStaticImageUrl; + private final Boolean isPrivate; + + public CourseListInfo(Course course) { + this.courseId = course.getId(); + this.title = course.getTitle(); + this.rate = course.getRate(); + this.spotCount = course.getSpots().size(); + this.createdDate = course.getCreatedAt(); + this.isPrivate = course.isPrivate(); + this.mapStaticImageUrl = course.getMapStaticImageFileUrl(); + } + } + + @Getter + @ToString + public static class Slice { + private final List courses; + private final boolean hasNext; + + public Slice(List courses, boolean hasNext) { + this.courses = courses; + this.hasNext = hasNext; + } + } + + @Getter + @ToString + public static class CourseSearchInfo { + private final Long id; + private final String title; + private final String mapStaticImageUrl; + private final String ownerProfileImageUrl; + private final String ownerNickname; + private final int spotCount; + private final double rate; + private final LocalDateTime createDate; + private final boolean liked; + + public CourseSearchInfo(Course course, boolean isLiked) { + this.id = course.getId(); + this.title = course.getTitle(); + this.mapStaticImageUrl = course.getMapStaticImageFileUrl(); + this.ownerProfileImageUrl = course.getMember().getProfileImageUrl(); + this.ownerNickname = course.getMember().getNickname(); + this.spotCount = course.getSpots().size(); + this.rate = course.getRate(); + this.createDate = course.getCreatedAt(); + this.liked = isLiked; + } + } + + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseReader.java new file mode 100644 index 000000000..b4e3913fb --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseReader.java @@ -0,0 +1,15 @@ +package kr.co.yigil.travel.domain.course; + +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.domain.Course; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface CourseReader { + Course getCourse(Long courseId); + Slice getCoursesSliceInPlace(Long placeId, Pageable pageable); + Page getMemberCourseList(Long memberId, Pageable pageable, Selected selectInfo); + Slice searchCourseByPlaceName(String keyword, Pageable pageable); +} + diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseSeriesFactory.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseSeriesFactory.java new file mode 100644 index 000000000..2212d1ea4 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseSeriesFactory.java @@ -0,0 +1,8 @@ +package kr.co.yigil.travel.domain.course; + +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.course.CourseCommand.ModifyCourseRequest; + +public interface CourseSeriesFactory { + Course modify(ModifyCourseRequest courseRequest, Course course); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseService.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseService.java new file mode 100644 index 000000000..59d578436 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseService.java @@ -0,0 +1,24 @@ +package kr.co.yigil.travel.domain.course; + +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.course.CourseCommand.ModifyCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequestWithSpotInfo; +import kr.co.yigil.travel.domain.course.CourseInfo.Main; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface CourseService { + + Slice getCoursesSliceInPlace(Long placeId, Pageable pageable); + void registerCourse(RegisterCourseRequest request, Long memberId); + void registerCourseWithoutSeries(RegisterCourseRequestWithSpotInfo request, Long memberId); + Main retrieveCourseInfo(Long courseId); + Course modifyCourse(ModifyCourseRequest command, Long courseId, Long memberId); + void deleteCourse(Long courseId, Long memberId); + + CourseInfo.MyCoursesResponse retrieveCourseList(Long memberId, Pageable pageable, Selected selected); + CourseInfo.Slice searchCourseByPlaceName(String keyword, Accessor accessor, Pageable pageable); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseServiceImpl.java new file mode 100644 index 000000000..e13e4b41a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseServiceImpl.java @@ -0,0 +1,117 @@ +package kr.co.yigil.travel.domain.course; + +import static kr.co.yigil.global.exception.ExceptionCode.INVALID_AUTHORITY; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.favor.domain.FavorReader; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.global.Selected; +import kr.co.yigil.global.exception.AuthException; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.course.CourseCommand.ModifyCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequestWithSpotInfo; +import kr.co.yigil.travel.domain.course.CourseInfo.CourseSearchInfo; +import kr.co.yigil.travel.domain.course.CourseInfo.Main; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CourseServiceImpl implements CourseService { + + private final MemberReader memberReader; + private final CourseReader courseReader; + private final FavorReader favorReader; + + private final CourseStore courseStore; + + private final CourseSeriesFactory courseSeriesFactory; + private final CourseSpotSeriesFactory courseSpotSeriesFactory; + + private final FileUploader fileUploader; + + @Override + @Transactional(readOnly = true) + public Slice getCoursesSliceInPlace(final Long placeId, final Pageable pageable) { + return courseReader.getCoursesSliceInPlace(placeId, pageable); + } + + @Override + @Transactional + public void registerCourse(final RegisterCourseRequest command, final Long memberId) { + Member member = memberReader.getMember(memberId); + var spots = courseSpotSeriesFactory.store(command, memberId); + var mapStaticImage = fileUploader.upload(command.getMapStaticImageFile()); + var initCourse = command.toEntity(spots, member, mapStaticImage); + courseStore.store(initCourse); + } + + @Override + @Transactional + public void registerCourseWithoutSeries(final RegisterCourseRequestWithSpotInfo command, + final Long memberId) { + Member member = memberReader.getMember(memberId); + var spots = courseSpotSeriesFactory.store(command, memberId); + var mapStaticImage = fileUploader.upload(command.getMapStaticImageFile()); + var initCourse = command.toEntity(spots, member, mapStaticImage); + courseStore.store(initCourse); + } + + @Override + @Transactional(readOnly = true) + public Main retrieveCourseInfo(final Long courseId) { + var course = courseReader.getCourse(courseId); + return new Main(course); + } + + @Override + @Transactional + public Course modifyCourse(final ModifyCourseRequest command, final Long courseId, final Long memberId) { + var course = courseReader.getCourse(courseId); + if(!Objects.equals(course.getMember().getId(), memberId)) throw new AuthException(INVALID_AUTHORITY); + return courseSeriesFactory.modify(command, course); + } + + @Override + @Transactional + public void deleteCourse(final Long courseId, final Long memberId) { + var course = courseReader.getCourse(courseId); + if(!Objects.equals(course.getMember().getId(), memberId)) throw new AuthException(INVALID_AUTHORITY); + courseStore.remove(course); + course.getSpots().forEach(Spot::changeOutOfCourse); + } + + @Override + @Transactional(readOnly = true) + public CourseInfo.MyCoursesResponse retrieveCourseList(final Long memberId, final Pageable pageable, + Selected visibility) { + var pageCourse = courseReader.getMemberCourseList(memberId, pageable, visibility); + List courseInfoList = pageCourse.getContent().stream() + .map(CourseInfo.CourseListInfo::new) + .toList(); + return new CourseInfo.MyCoursesResponse(courseInfoList, pageCourse.getTotalPages()); + } + + @Override + @Transactional(readOnly = true) + public CourseInfo.Slice searchCourseByPlaceName(String keyword, Accessor accessor, + Pageable pageable) { + var result = courseReader.searchCourseByPlaceName(keyword, pageable); + var courses = result.getContent().stream() + .map(course -> { + boolean isLiked = accessor.isMember() && favorReader.existsByMemberIdAndTravelId(accessor.getMemberId(), course.getId()); + return new CourseSearchInfo(course, isLiked); + }).toList(); + return new CourseInfo.Slice(courses, result.hasNext()); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseSpotSeriesFactory.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseSpotSeriesFactory.java new file mode 100644 index 000000000..4ddddb072 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseSpotSeriesFactory.java @@ -0,0 +1,10 @@ +package kr.co.yigil.travel.domain.course; + +import java.util.List; +import kr.co.yigil.travel.domain.Spot; + +public interface CourseSpotSeriesFactory { + List store(CourseCommand.RegisterCourseRequest request, Long memberId); + + List store(CourseCommand.RegisterCourseRequestWithSpotInfo request, Long memberId); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseStore.java new file mode 100644 index 000000000..3633b8db5 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/course/CourseStore.java @@ -0,0 +1,8 @@ +package kr.co.yigil.travel.domain.course; + +import kr.co.yigil.travel.domain.Course; + +public interface CourseStore { + Course store(Course initStore); + void remove(Course course); +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/repository/CourseRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/repository/CourseRepository.java deleted file mode 100644 index 7f27bf527..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/repository/CourseRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.co.yigil.travel.domain.repository; - -import kr.co.yigil.travel.domain.Course; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CourseRepository extends JpaRepository { - -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/repository/SpotRepository.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/repository/SpotRepository.java deleted file mode 100644 index a64fa38e6..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/repository/SpotRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package kr.co.yigil.travel.domain.repository; - -import kr.co.yigil.travel.domain.Spot; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface SpotRepository extends JpaRepository { - -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotCommand.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotCommand.java new file mode 100644 index 000000000..8341e8101 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotCommand.java @@ -0,0 +1,100 @@ +package kr.co.yigil.travel.domain.spot; + +import java.time.LocalDateTime; +import java.util.List; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.AttachFiles; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.util.GeojsonConverter; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.web.multipart.MultipartFile; + +public class SpotCommand { + + @Getter + @Builder + @ToString + public static class RegisterSpotRequest { + + private final String pointJson; + private final String title; + private final String description; + private final double rate; + private final List files; + private final RegisterPlaceRequest registerPlaceRequest; + + public Spot toEntity(Member member, Place place, boolean isInCourse, AttachFiles attachFiles) { + return new Spot( + member, + GeojsonConverter.convertToPoint(pointJson), + isInCourse, + title, + description, + attachFiles, + place, + rate + ); + } + } + + @Getter + @Builder + @ToString + public static class RegisterPlaceRequest { + + private final MultipartFile mapStaticImageFile; + private final MultipartFile placeImageFile; + private final String placeName; + private final String placeAddress; + private final String placePointJson; + + public Place toEntity(AttachFile placeImage, AttachFile mapStaticImage) { + return new Place(placeName, placeAddress, 0.0, + GeojsonConverter.convertToPoint(placePointJson), placeImage, mapStaticImage, LocalDateTime.now()); + } + } + + @Getter + @Builder + @ToString + @AllArgsConstructor + public static class ModifySpotRequest { + private final Long id; + private final double rate; + private final String description; + private final List originalImages; + private final List updatedImages; + } + + @Getter + @Builder + @ToString + @AllArgsConstructor + public static class OriginalSpotImage { + private String imageUrl; + private int index; + } + + @Getter + @Builder + @ToString + @AllArgsConstructor + public static class UpdateSpotImage { + private MultipartFile imageFile; + private int index; + } + + @Getter + @Builder + @ToString + public static class IndexedAttachFiles { + private AttachFile file; + private int index; + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotInfo.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotInfo.java new file mode 100644 index 000000000..5cac475ea --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotInfo.java @@ -0,0 +1,130 @@ +package kr.co.yigil.travel.domain.spot; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import kr.co.yigil.travel.domain.Spot; +import lombok.Getter; +import lombok.ToString; + +public class SpotInfo { + + @Getter + @ToString + public static class Main { + private final Long id; + private final String placeName; + private final double rate; + private final String placeAddress; + private final String mapStaticImageFileUrl; + private final List imageUrls; + private final LocalDateTime createDate; + private final String description; + private final String ownerProfileImageUrl; + private final String ownerNickname; + private final boolean liked; + + public Main(Spot spot) { + id = spot.getId(); + placeName = spot.getPlace().getName(); + placeAddress = spot.getPlace().getAddress(); + rate = spot.getRate(); + mapStaticImageFileUrl = spot.getPlace().getMapStaticImageFileUrl(); + imageUrls = spot.getAttachFiles().getUrls(); + createDate = spot.getCreatedAt(); + description = spot.getDescription(); + ownerProfileImageUrl = spot.getMember().getProfileImageUrl(); + ownerNickname = spot.getMember().getNickname(); + this.liked = false; + } + + public Main(Spot spot, boolean liked) { + id = spot.getId(); + placeName = spot.getPlace().getName(); + placeAddress = spot.getPlace().getAddress(); + rate = spot.getRate(); + mapStaticImageFileUrl = spot.getPlace().getMapStaticImageFileUrl(); + imageUrls = spot.getAttachFiles().getUrls(); + createDate = spot.getCreatedAt(); + description = spot.getDescription(); + ownerProfileImageUrl = spot.getMember().getProfileImageUrl(); + ownerNickname = spot.getMember().getNickname(); + this.liked = liked; + } + } + + @Getter + @ToString + public static class MySpotsResponse { + + private final List content; + private final int totalPages; + + public MySpotsResponse(List spotList, int totalPages) { + this.content = spotList; + this.totalPages = totalPages; + } + } + + @Getter + @ToString + public static class SpotListInfo { + + private final Long spotId; + private final String title; + private final double rate; + private final String imageUrl; + private final LocalDateTime createdDate; + private final Boolean isPrivate; + + public SpotListInfo(Spot spot) { + this.spotId = spot.getId(); + this.title = spot.getPlace().getName(); + this.rate = spot.getRate(); + this.imageUrl = spot.getAttachFiles().getUrls().getFirst(); + this.createdDate = spot.getCreatedAt(); + this.isPrivate = spot.isPrivate(); + } + } + + + + @Getter + @ToString + public static class MySpot { + private final double rate; + private final List imageUrls; + private final LocalDateTime createDate; + private final String description; + private final boolean exists; + + public MySpot(Optional spotOptional) { + if(spotOptional.isEmpty()) { + exists = false; + rate = 0; + imageUrls = List.of(""); + createDate = LocalDateTime.now(); + description = ""; + } else { + exists = true; + var spot = spotOptional.get(); + rate = spot.getRate(); + imageUrls = spot.getAttachFiles().getUrls(); + createDate = spot.getCreatedAt(); + description = spot.getDescription(); + } + } + } + + @Getter + @ToString + public static class Slice { + private final List
mains; + private final boolean hasNext; + + public Slice(List
mains, boolean hasNext) { + this.mains = mains; + this.hasNext = hasNext; + } + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotReader.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotReader.java new file mode 100644 index 000000000..456121490 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotReader.java @@ -0,0 +1,26 @@ +package kr.co.yigil.travel.domain.spot; + +import java.util.List; +import java.util.Optional; +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.domain.Spot; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface SpotReader { + Spot getSpot(Long spotId); + + Optional findSpotByPlaceIdAndMemberId(Long placeId, Long memberId); + + List getSpots(List spotIds); + + Slice getSpotSliceInPlace(Long placeId, Pageable pageable); + + int getSpotCountInPlace(Long placeId); + + Page getSpotSliceByMemberId(Long memberId, Pageable pageable); + + Page getMemberSpotList(Long memberId, Selected selected, Pageable pageable); + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotSeriesFactory.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotSeriesFactory.java new file mode 100644 index 000000000..786ef0c52 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotSeriesFactory.java @@ -0,0 +1,13 @@ +package kr.co.yigil.travel.domain.spot; + +import kr.co.yigil.file.AttachFiles; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.spot.SpotCommand.ModifySpotRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterSpotRequest; + +public interface SpotSeriesFactory { + + Spot modify(ModifySpotRequest command, Spot spot); + + AttachFiles initAttachFiles(RegisterSpotRequest command); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotService.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotService.java new file mode 100644 index 000000000..5ba988cd7 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotService.java @@ -0,0 +1,26 @@ +package kr.co.yigil.travel.domain.spot; + +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.domain.spot.SpotCommand.ModifySpotRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterSpotRequest; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpotsResponse; +import kr.co.yigil.travel.domain.spot.SpotInfo.Slice; +import org.springframework.data.domain.Pageable; + +public interface SpotService { + + Slice getSpotSliceInPlace(Long placeId, Accessor accessor, Pageable pageable); + + public SpotInfo.MySpot retrieveMySpotInfoInPlace(Long placeId, Long memberId); + + void registerSpot(RegisterSpotRequest command, Long memberId); + + public SpotInfo.Main retrieveSpotInfo(Long spotId); + + void modifySpot(ModifySpotRequest command, Long spotId, Long memberId); + + void deleteSpot(Long spotId, Long memberId); + + MySpotsResponse retrieveSpotList(Long memberId, Selected selected, Pageable pageable); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotServiceImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotServiceImpl.java new file mode 100644 index 000000000..da86864f5 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotServiceImpl.java @@ -0,0 +1,118 @@ +package kr.co.yigil.travel.domain.spot; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.favor.domain.FavorReader; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.global.Selected; +import kr.co.yigil.global.exception.AuthException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCacheStore; +import kr.co.yigil.place.domain.PlaceReader; +import kr.co.yigil.place.domain.PlaceStore; +import kr.co.yigil.travel.domain.spot.SpotCommand.ModifySpotRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterPlaceRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterSpotRequest; +import kr.co.yigil.travel.domain.spot.SpotInfo.Main; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpot; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpotsResponse; +import kr.co.yigil.travel.domain.spot.SpotInfo.Slice; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SpotServiceImpl implements SpotService { + + private final MemberReader memberReader; + private final SpotReader spotReader; + private final PlaceReader placeReader; + private final FavorReader favorReader; + + private final SpotStore spotStore; + private final PlaceStore placeStore; + private final PlaceCacheStore placeCacheStore; + + private final SpotSeriesFactory spotSeriesFactory; + private final FileUploader fileUploader; + + @Override + @Transactional(readOnly = true) + public Slice getSpotSliceInPlace(final Long placeId, final Accessor accessor, final Pageable pageable) { + var slice = spotReader.getSpotSliceInPlace(placeId, pageable); + var mains = slice.getContent() + .stream() + .map(spot -> { + boolean isLiked = accessor.isMember() && favorReader.existsByMemberIdAndTravelId(accessor.getMemberId(), spot.getId()); + return new Main(spot, isLiked); + }).collect(Collectors.toList()); + return new Slice(mains, slice.hasNext()); + } + + @Override + @Transactional(readOnly = true) + public MySpot retrieveMySpotInfoInPlace(Long placeId, Long memberId) { + var spotOptional = spotReader.findSpotByPlaceIdAndMemberId(placeId, memberId); + return new MySpot(spotOptional); + } + + @Override + @Transactional + public void registerSpot(RegisterSpotRequest command, Long memberId) { + Member member = memberReader.getMember(memberId); + Optional optionalPlace = placeReader.findPlaceByNameAndAddress(command.getRegisterPlaceRequest().getPlaceName(), command.getRegisterPlaceRequest().getPlaceAddress()); + Place place = optionalPlace.orElseGet(()-> registerNewPlace(command.getRegisterPlaceRequest())); + var attachFiles = spotSeriesFactory.initAttachFiles(command); + var spot = spotStore.store(command.toEntity(member, place, false, attachFiles)); + var spotCount = placeCacheStore.incrementSpotCountInPlace(place.getId()); + } + + @Override + @Transactional(readOnly = true) + public Main retrieveSpotInfo(Long spotId) { + var spot = spotReader.getSpot(spotId); + return new Main(spot); + } + + @Override + @Transactional + public void modifySpot(ModifySpotRequest command, Long spotId, Long memberId) { + var spot = spotReader.getSpot(spotId); + if(!Objects.equals(spot.getMember().getId(), memberId)) throw new AuthException(ExceptionCode.INVALID_AUTHORITY); + spotSeriesFactory.modify(command, spot); + } + + @Override + @Transactional + public void deleteSpot(Long spotId, Long memberId) { + var spot = spotReader.getSpot(spotId); + if(!Objects.equals(spot.getMember().getId(), memberId)) throw new AuthException( + ExceptionCode.INVALID_AUTHORITY); + spotStore.remove(spot); + if(spot.getPlace() != null) placeCacheStore.decrementSpotCountInPlace(spot.getPlace().getId()); + } + + private Place registerNewPlace(RegisterPlaceRequest command) { + var placeImageFile = fileUploader.upload(command.getPlaceImageFile()); + var mapStaticImage = fileUploader.upload(command.getMapStaticImageFile()); + return placeStore.store(command.toEntity(placeImageFile, mapStaticImage)); + } + + @Override + @Transactional + public MySpotsResponse retrieveSpotList(Long memberId, Selected visibility, Pageable pageable) { + var pageSpot = spotReader.getMemberSpotList(memberId, visibility, pageable); + List spotInfoList = pageSpot.getContent().stream() + .map(SpotInfo.SpotListInfo::new) + .toList(); + return new MySpotsResponse(spotInfoList, pageSpot.getTotalPages()); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotStore.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotStore.java new file mode 100644 index 000000000..20099f125 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/domain/spot/SpotStore.java @@ -0,0 +1,9 @@ +package kr.co.yigil.travel.domain.spot; + +import kr.co.yigil.travel.domain.Spot; + +public interface SpotStore { + Spot store(Spot initSpot); + + void remove(Spot spot); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/SpotInCourseDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/SpotInCourseDto.java deleted file mode 100644 index 3b0868a4d..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/SpotInCourseDto.java +++ /dev/null @@ -1,21 +0,0 @@ -package kr.co.yigil.travel.dto; - -import kr.co.yigil.travel.domain.Spot; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class SpotInCourseDto { - private String title; - private String fileUrl; - private String description; - - public static SpotInCourseDto from(Spot spot) { - return new SpotInCourseDto(spot.getTitle(), spot.getFileUrl(), spot.getDescription()); - - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/CourseCreateRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/CourseCreateRequest.java deleted file mode 100644 index d8a701e91..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/CourseCreateRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -package kr.co.yigil.travel.dto.request; - -import java.util.List; - -import kr.co.yigil.travel.domain.Course; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.dto.util.GeojsonConverter; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class CourseCreateRequest { - private String title; - private int representativeSpotOrder; - private List spotIds; - private String lineStringJson; - - public static Course toEntity(CourseCreateRequest courseCreateRequest, List spots) { - return new Course( - GeojsonConverter.convertToLineString(courseCreateRequest.getLineStringJson()), - spots, - courseCreateRequest.getRepresentativeSpotOrder(), - courseCreateRequest.getTitle() - ); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/CourseUpdateRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/CourseUpdateRequest.java deleted file mode 100644 index ba15e5f03..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/CourseUpdateRequest.java +++ /dev/null @@ -1,34 +0,0 @@ -package kr.co.yigil.travel.dto.request; - -import java.util.List; -import kr.co.yigil.travel.domain.Course; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.dto.util.GeojsonConverter; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class CourseUpdateRequest { - private String title; - private String lineStringJson; - - private int representativeSpotOrder; - private List spotIds; - - private List removedSpotIds; - private List addedSpotIds; - - public static Course toEntity(Long courseId, CourseUpdateRequest courseUpdateRequest, List spots) { - return new Course( - courseId, - GeojsonConverter.convertToLineString(courseUpdateRequest.getLineStringJson()), - spots, - courseUpdateRequest.getRepresentativeSpotOrder(), - courseUpdateRequest.getTitle() - ); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/SpotCreateRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/SpotCreateRequest.java deleted file mode 100644 index 2fce6bd42..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/SpotCreateRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -package kr.co.yigil.travel.dto.request; - -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.dto.util.GeojsonConverter; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.web.multipart.MultipartFile; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class SpotCreateRequest { - private String pointJson; - private String title; - private String description; - private MultipartFile file; - - public static Spot toEntity(SpotCreateRequest spotCreateRequest,String fileUrl) { - return new Spot( - GeojsonConverter.convertToPoint(spotCreateRequest.getPointJson()), - spotCreateRequest.getTitle(), - spotCreateRequest.getDescription(), - fileUrl - ); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/SpotUpdateRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/SpotUpdateRequest.java deleted file mode 100644 index 758717deb..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/request/SpotUpdateRequest.java +++ /dev/null @@ -1,33 +0,0 @@ -package kr.co.yigil.travel.dto.request; - -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.dto.util.GeojsonConverter; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.locationtech.jts.geom.Point; -import org.springframework.web.multipart.MultipartFile; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class SpotUpdateRequest { - private String pointJson; - private Boolean isInCourse; - private String title; - private String description; - private MultipartFile file; - - public static Spot toEntity(Long spotId, SpotUpdateRequest spotUpdateRequest,String fileUrl) { - return new Spot( - spotId, - GeojsonConverter.convertToPoint(spotUpdateRequest.getPointJson()), - spotUpdateRequest.getIsInCourse(), - spotUpdateRequest.getTitle(), - spotUpdateRequest.getDescription(), - fileUrl - ); - } - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/CourseCreateResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/CourseCreateResponse.java deleted file mode 100644 index e5b7f8e62..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/CourseCreateResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package kr.co.yigil.travel.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@AllArgsConstructor -@NoArgsConstructor -public class CourseCreateResponse { - private String message; -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/CourseFindResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/CourseFindResponse.java deleted file mode 100644 index 5c9576468..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/CourseFindResponse.java +++ /dev/null @@ -1,46 +0,0 @@ -package kr.co.yigil.travel.dto.response; - -import java.util.List; -import kr.co.yigil.comment.dto.response.CommentResponse; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.post.dto.response.PostResponse; -import kr.co.yigil.travel.domain.Course; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.dto.SpotInCourseDto; -import kr.co.yigil.travel.dto.util.GeojsonConverter; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class CourseFindResponse { - private String title; - - private String memberNickname; - private String memberImageUrl; - - private Integer likeCount; - private Integer commentCount; - - // 코스에 포함된 스팟 정보 - private List spotInfos; - private String lineStringJson; - - private List comments; - - public static CourseFindResponse from(Post post, Course course, List spots, List comments) { - return new CourseFindResponse( - course.getTitle(), - post.getMember().getNickname(), - post.getMember().getProfileImageUrl(), - 0, // likeCount 초기화 - 0, // commentCount 초기화 - spots.stream().map(SpotInCourseDto::from).toList(), - GeojsonConverter.convertToJson(course.getPath()), - comments - ); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/CourseUpdateResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/CourseUpdateResponse.java deleted file mode 100644 index 2aa24d824..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/CourseUpdateResponse.java +++ /dev/null @@ -1,42 +0,0 @@ -package kr.co.yigil.travel.dto.response; - -import java.util.List; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.post.dto.response.PostResponse; -import kr.co.yigil.travel.domain.Course; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.dto.SpotInCourseDto; -import kr.co.yigil.travel.dto.util.GeojsonConverter; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class CourseUpdateResponse { - private String title; - - private String memberNickname; - private String memberImageUrl; - - private Integer likeCount; - private Integer commentCount; - - // 코스에 포함된 스팟 정보 - private List spotInfos; - private String lineStringJson; - - public static CourseUpdateResponse from(Member member, Course course, List spots) { - return new CourseUpdateResponse( - course.getTitle(), - member.getNickname(), - member.getProfileImageUrl(), - 0, // likeCount 초기화 - 0, // commentCount 초기화 - spots.stream().map(SpotInCourseDto::from).toList(), - GeojsonConverter.convertToJson(course.getPath()) - ); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/SpotFindResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/SpotFindResponse.java deleted file mode 100644 index 4e8edc56a..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/SpotFindResponse.java +++ /dev/null @@ -1,43 +0,0 @@ -package kr.co.yigil.travel.dto.response; - -import java.util.List; -import kr.co.yigil.comment.dto.response.CommentResponse; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.dto.util.GeojsonConverter; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class SpotFindResponse { - // 세부 spot response - private String title; - private String fileUrl; - private String description; - - private String memberNickname; - private String memberImageUrl; - -// private Integer likeCount; -// private Integer commentCount; - - private String pointJson; - - private List comments; - - public static SpotFindResponse from(Member member, Spot spot, List comments) { - return new SpotFindResponse( - spot.getTitle(), - spot.getFileUrl(), - spot.getDescription(), - member.getNickname(), - member.getProfileImageUrl(), - GeojsonConverter.convertToJson(spot.getLocation()), - comments - ); - } -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/SpotUpdateResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/SpotUpdateResponse.java deleted file mode 100644 index 515fac203..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/response/SpotUpdateResponse.java +++ /dev/null @@ -1,32 +0,0 @@ -package kr.co.yigil.travel.dto.response; - -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.dto.util.GeojsonConverter; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class SpotUpdateResponse { - private String title; - private String fileUrl; - private String description; - private String memberNickname; - private String memberImageUrl; - private String pointJson; - - public static SpotUpdateResponse from(Member member, Spot spot) { - return new SpotUpdateResponse( - spot.getTitle(), - spot.getFileUrl(), - spot.getDescription(), - member.getNickname(), - member.getProfileImageUrl(), - GeojsonConverter.convertToJson(spot.getLocation()) - ); - } - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/TravelReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/TravelReaderImpl.java new file mode 100644 index 000000000..78aae21d2 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/TravelReaderImpl.java @@ -0,0 +1,27 @@ +package kr.co.yigil.travel.infrastructure; + +import java.util.List; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.travel.domain.Travel; +import kr.co.yigil.travel.domain.TravelReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TravelReaderImpl implements TravelReader { + private final TravelRepository travelRepository; + + @Override + public Travel getTravel(Long travelId) { + return travelRepository.findById(travelId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_TRAVEL_ID)); + } + + @Override + public List getTravels(List travelIds) { + return travelRepository.findAllById(travelIds); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseReaderImpl.java new file mode 100644 index 000000000..78df80317 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseReaderImpl.java @@ -0,0 +1,44 @@ +package kr.co.yigil.travel.infrastructure.course; + +import kr.co.yigil.global.Selected; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.course.CourseReader; +import kr.co.yigil.travel.infrastructure.CourseQueryDslRepository; +import kr.co.yigil.travel.infrastructure.CourseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CourseReaderImpl implements CourseReader { + + private final CourseRepository courseRepository; + private final CourseQueryDslRepository courseQueryDslRepository; + + @Override + public Course getCourse(final Long courseId) { + return courseRepository.findById(courseId) + .orElseThrow(() -> new BadRequestException(ExceptionCode.NOT_FOUND_COURSE_ID)); + } + + @Override + public Slice getCoursesSliceInPlace(final Long placeId, final Pageable pageable) { + return courseRepository.findBySpotPlaceId(placeId, pageable); + } + + @Override + public Page getMemberCourseList(final Long memberId, final Pageable pageable, + final Selected visibility) { + return courseQueryDslRepository.findAllByMemberIdAndIsPrivate(memberId, visibility, pageable); + } + + @Override + public Slice searchCourseByPlaceName(final String keyword, final Pageable pageable) { + return courseRepository.findByPlaceNameContaining(keyword, pageable); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseSeriesFactoryImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseSeriesFactoryImpl.java new file mode 100644 index 000000000..50b6fb78d --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseSeriesFactoryImpl.java @@ -0,0 +1,41 @@ +package kr.co.yigil.travel.infrastructure.course; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.course.CourseCommand.ModifyCourseRequest; +import kr.co.yigil.travel.domain.course.CourseSeriesFactory; +import kr.co.yigil.travel.domain.spot.SpotReader; +import kr.co.yigil.travel.domain.spot.SpotSeriesFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CourseSeriesFactoryImpl implements CourseSeriesFactory { + + private final SpotSeriesFactory spotSeriesFactory; + private final SpotReader spotReader; + + @Override + public Course modify(ModifyCourseRequest command, Course course) { + Map existingSpotMap = course.getSpots().stream() + .collect(Collectors.toMap(Spot::getId, spot -> spot)); + + List modifiedSpots = command.getModifySpotRequests().stream() + .map(request -> spotSeriesFactory.modify(request, spotReader.getSpot(request.getId()))) + .toList(); + + List sortedSpots = command.getSpotIdOrder().stream() + .map(existingSpotMap::get) + .collect(Collectors.toList()); + + course.updateCourse(command.getDescription(), command.getRate(), sortedSpots); + + return course; + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseSpotSeriesFactoryImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseSpotSeriesFactoryImpl.java new file mode 100644 index 000000000..2497094cc --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseSpotSeriesFactoryImpl.java @@ -0,0 +1,73 @@ +package kr.co.yigil.travel.infrastructure.course; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import kr.co.yigil.file.AttachFiles; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCacheStore; +import kr.co.yigil.place.domain.PlaceReader; +import kr.co.yigil.place.domain.PlaceStore; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequestWithSpotInfo; +import kr.co.yigil.travel.domain.course.CourseSpotSeriesFactory; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterPlaceRequest; +import kr.co.yigil.travel.domain.spot.SpotReader; +import kr.co.yigil.travel.domain.spot.SpotStore; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +@Component +@RequiredArgsConstructor +public class CourseSpotSeriesFactoryImpl implements CourseSpotSeriesFactory { + private final MemberReader memberReader; + private final PlaceReader placeReader; + private final SpotReader spotReader; + + private final PlaceStore placeStore; + private final SpotStore spotStore; + private final PlaceCacheStore placeCacheStore; + + private final FileUploader fileUploader; + @Override + public List store(RegisterCourseRequest request, Long memberId) { + var courseSpotRequestList = request.getRegisterSpotRequests(); + if (CollectionUtils.isEmpty(courseSpotRequestList)) return Collections.emptyList(); + Member member = memberReader.getMember(memberId); + + return courseSpotRequestList.stream() + .map(registerSpotRequest -> { + var registerPlaceRequest = registerSpotRequest.getRegisterPlaceRequest(); + Optional optionalPlace = placeReader.findPlaceByNameAndAddress(registerPlaceRequest.getPlaceName(), registerPlaceRequest.getPlaceAddress()); + Place place = optionalPlace.orElseGet(() -> registerNewPlace(registerPlaceRequest)); + + var attachFiles = new AttachFiles(registerSpotRequest.getFiles().stream() + .map(fileUploader::upload) + .collect(Collectors.toList())); + + var spot = spotStore.store(registerSpotRequest.toEntity(member, place, true, attachFiles)); + + var spotCount = placeCacheStore.incrementSpotCountInPlace(place.getId()); + return spot; + }).collect(Collectors.toList()); + } + + @Override + public List store(RegisterCourseRequestWithSpotInfo request, Long memberId) { + List spots = spotReader.getSpots(request.getSpotIds()); + spots.forEach(Spot::changeInCourse); + return spots; + } + + private Place registerNewPlace(RegisterPlaceRequest command) { + var placeImage = fileUploader.upload(command.getPlaceImageFile()); + var mapStaticImage = fileUploader.upload(command.getMapStaticImageFile()); + return placeStore.store(command.toEntity(placeImage, mapStaticImage)); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseStoreImpl.java new file mode 100644 index 000000000..125d4e523 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/course/CourseStoreImpl.java @@ -0,0 +1,24 @@ +package kr.co.yigil.travel.infrastructure.course; + +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.course.CourseStore; +import kr.co.yigil.travel.infrastructure.CourseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CourseStoreImpl implements CourseStore { + private final CourseRepository courseRepository; + + @Override + public Course store(Course initCourse) { + return courseRepository.save(initCourse); + } + + @Override + public void remove(Course course) { + courseRepository.delete(course); + } +} + diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/spot/SpotReaderImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/spot/SpotReaderImpl.java new file mode 100644 index 000000000..787ac70bd --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/spot/SpotReaderImpl.java @@ -0,0 +1,66 @@ +package kr.co.yigil.travel.infrastructure.spot; + +import static kr.co.yigil.global.exception.ExceptionCode.NOT_FOUND_SPOT_ID; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import kr.co.yigil.global.Selected; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.spot.SpotReader; +import kr.co.yigil.travel.infrastructure.SpotQueryDslRepository; +import kr.co.yigil.travel.infrastructure.SpotRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpotReaderImpl implements SpotReader { + + private final SpotRepository spotRepository; + private final SpotQueryDslRepository spotQueryDslRepository; + + @Override + public Spot getSpot(Long spotId) { + return spotRepository.findById(spotId) + .orElseThrow(() -> new BadRequestException(NOT_FOUND_SPOT_ID)); + } + + @Override + public Optional findSpotByPlaceIdAndMemberId(Long placeId, Long memberId) { + return spotRepository.findTopByPlaceIdAndMemberId(placeId, memberId); + } + + @Override + public List getSpots(List spotIds) { + return spotIds.stream() + .map(this::getSpot) + .collect(Collectors.toList()); + } + + @Override + public Slice getSpotSliceInPlace(Long placeId, Pageable pageable) { + return spotRepository.findAllByPlaceIdAndIsInCourseIsFalseAndIsPrivateIsFalse(placeId, + pageable); + } + + @Override + public int getSpotCountInPlace(Long placeId) { + return spotRepository.countByPlaceId(placeId); + } + + @Override + public Page getSpotSliceByMemberId(Long memberId, Pageable pageable) { + return spotRepository.findAllByMemberIdAndIsInCourseIsFalse(memberId, pageable); + } + + @Override + public Page getMemberSpotList(Long memberId, Selected visibility, Pageable pageable + ) { + return spotQueryDslRepository.findAllByMemberIdAndIsPrivate(memberId, visibility, pageable); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/spot/SpotSeriesFactoryImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/spot/SpotSeriesFactoryImpl.java new file mode 100644 index 000000000..99491fee1 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/spot/SpotSeriesFactoryImpl.java @@ -0,0 +1,61 @@ +package kr.co.yigil.travel.infrastructure.spot; + +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.AttachFiles; +import kr.co.yigil.file.FileReader; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.spot.SpotCommand.ModifySpotRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterSpotRequest; +import kr.co.yigil.travel.domain.spot.SpotSeriesFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpotSeriesFactoryImpl implements SpotSeriesFactory { + + private final FileReader fileReader; + private final FileUploader fileUploader; + + @Override + public Spot modify(ModifySpotRequest command, Spot spot) { + List spotComibinedImageList = command.getOriginalImages().stream() + .map(image -> new CombinedImage( + fileReader.getFileByUrl(image.getImageUrl()), image.getIndex())) + .collect(Collectors.toCollection(LinkedList::new)); + if(command.getUpdatedImages() != null) { + spotComibinedImageList.addAll(command.getUpdatedImages().stream() + .map(image -> new CombinedImage( + fileUploader.upload(image.getImageFile()), image.getIndex())) + .toList()); + } + + + spotComibinedImageList.sort(Comparator.comparingInt(CombinedImage::index)); + + LinkedList sortedImages = new LinkedList<>(spotComibinedImageList); + + LinkedList attachFileLinkedList = sortedImages.stream() + .map(CombinedImage::attachFile) + .collect(Collectors.toCollection(LinkedList::new)); + + spot.updateSpot(command.getRate(), command.getDescription(), attachFileLinkedList); + + return spot; + } + + @Override + public AttachFiles initAttachFiles(RegisterSpotRequest command) { + return new AttachFiles(command.getFiles().stream() + .map(fileUploader::upload) + .collect(Collectors.toList())); + + } + + private record CombinedImage(AttachFile attachFile, int index) { } +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/spot/SpotStoreImpl.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/spot/SpotStoreImpl.java new file mode 100644 index 000000000..37ca0e60a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/infrastructure/spot/SpotStoreImpl.java @@ -0,0 +1,23 @@ +package kr.co.yigil.travel.infrastructure.spot; + +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.spot.SpotStore; +import kr.co.yigil.travel.infrastructure.SpotRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpotStoreImpl implements SpotStore { + private final SpotRepository spotRepository; + + @Override + public Spot store(Spot spot) { + return spotRepository.save(spot); + } + + @Override + public void remove(Spot spot) { + spotRepository.delete(spot); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/controller/CourseApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/controller/CourseApiController.java new file mode 100644 index 000000000..050c071b6 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/controller/CourseApiController.java @@ -0,0 +1,154 @@ +package kr.co.yigil.travel.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.Selected; +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import kr.co.yigil.travel.application.CourseFacade; +import kr.co.yigil.travel.interfaces.dto.CourseDetailInfoDto; +import kr.co.yigil.travel.interfaces.dto.mapper.CourseMapper; +import kr.co.yigil.travel.interfaces.dto.request.CourseRegisterRequest; +import kr.co.yigil.travel.interfaces.dto.request.CourseRegisterWithoutSeriesRequest; +import kr.co.yigil.travel.interfaces.dto.request.CourseUpdateRequest; +import kr.co.yigil.travel.interfaces.dto.response.CourseDeleteResponse; +import kr.co.yigil.travel.interfaces.dto.response.CourseRegisterResponse; +import kr.co.yigil.travel.interfaces.dto.response.CourseSearchResponse; +import kr.co.yigil.travel.interfaces.dto.response.CourseUpdateResponse; +import kr.co.yigil.travel.interfaces.dto.response.CoursesInPlaceResponse; +import kr.co.yigil.travel.interfaces.dto.response.MyCoursesResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/courses") +public class CourseApiController { + + private final CourseFacade courseFacade; + private final CourseMapper courseMapper; + + @GetMapping("/place/{placeId}") + public ResponseEntity getCoursesInPlace( + @PathVariable("placeId") Long placeId, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + var result = courseFacade.getCourseSliceInPlace(placeId, pageRequest); + var response = courseMapper.courseSliceToCourseInPlaceResponse(result); + return ResponseEntity.ok().body(response); + } + + @PostMapping + @MemberOnly + public ResponseEntity registerCourse( + @ModelAttribute CourseRegisterRequest request, + @Auth final Accessor accessor + ) { + Long memberId = accessor.getMemberId(); + var courseCommand = courseMapper.toRegisterCourseRequest(request); + courseFacade.registerCourse(courseCommand, memberId); + return ResponseEntity.ok().body(new CourseRegisterResponse("Course 생성 완료")); + } + + @PostMapping("/only") + @MemberOnly + public ResponseEntity registerCourseWithoutSeries( + @ModelAttribute CourseRegisterWithoutSeriesRequest request, + @Auth final Accessor accessor + ) { + Long memberId = accessor.getMemberId(); + var courseCommand = courseMapper.toRegisterCourseRequest(request); + courseFacade.registerCourseWithoutSeries(courseCommand, memberId); + return ResponseEntity.ok().body(new CourseRegisterResponse("Course 생성 완료")); + } + + @GetMapping("/{courseId}") + public ResponseEntity retrieveCourse( + @PathVariable("courseId") Long courseId) { + var courseInfo = courseFacade.retrieveCourseInfo(courseId); + var response = courseMapper.toCourseDetailInfoDto(courseInfo); + return ResponseEntity.ok().body(response); + } + + @PostMapping("/{courseId}") + @MemberOnly + public ResponseEntity updateCourse( + @PathVariable("courseId") Long courseId, + @ModelAttribute CourseUpdateRequest request, + @Auth final Accessor accessor + ) { + Long memberId = accessor.getMemberId(); + var courseCommand = courseMapper.toModifyCourseRequest(request); + courseFacade.modifyCourse(courseCommand, courseId, memberId); + return ResponseEntity.ok().body(new CourseUpdateResponse("Course 수정 완료")); + } + + @DeleteMapping("/{courseId}") + @MemberOnly + public ResponseEntity deleteSpot( + @PathVariable("courseId") Long courseId, + @Auth final Accessor accessor + ) { + Long memberId = accessor.getMemberId(); + courseFacade.deleteCourse(courseId, memberId); + return ResponseEntity.ok().body(new CourseDeleteResponse("Course 삭제 완료")); + } + + @GetMapping("/my") + @MemberOnly + public ResponseEntity getMyCourseList( + @Auth final Accessor accessor, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder, + @RequestParam(name = "selected", defaultValue = "all", required = false) Selected visibility + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + + final var memberCoursesInfo = courseFacade.getMemberCoursesInfo( + accessor.getMemberId(), pageRequest, visibility); + var myCoursesResponse = courseMapper.of(memberCoursesInfo); + return ResponseEntity.ok().body(myCoursesResponse); + } + + @GetMapping("/search") + public ResponseEntity searchCourseByPlaceName( + @Auth Accessor accessor, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam String keyword, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + + var result = courseFacade.searchCourseByPlaceName(keyword, accessor, pageRequest); + var response = courseMapper.toCourseSearchResponse(result); + return ResponseEntity.ok().body(response); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/controller/SpotApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/controller/SpotApiController.java new file mode 100644 index 000000000..9442b27d9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/controller/SpotApiController.java @@ -0,0 +1,136 @@ +package kr.co.yigil.travel.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.Selected; +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import kr.co.yigil.travel.application.SpotFacade; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpotsResponse; +import kr.co.yigil.travel.interfaces.dto.SpotDetailInfoDto; +import kr.co.yigil.travel.interfaces.dto.mapper.SpotMapper; +import kr.co.yigil.travel.interfaces.dto.request.SpotRegisterRequest; +import kr.co.yigil.travel.interfaces.dto.request.SpotUpdateRequest; +import kr.co.yigil.travel.interfaces.dto.response.MySpotInPlaceResponse; +import kr.co.yigil.travel.interfaces.dto.response.MySpotsResponseDto; +import kr.co.yigil.travel.interfaces.dto.response.SpotDeleteResponse; +import kr.co.yigil.travel.interfaces.dto.response.SpotRegisterResponse; +import kr.co.yigil.travel.interfaces.dto.response.SpotUpdateResponse; +import kr.co.yigil.travel.interfaces.dto.response.SpotsInPlaceResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/spots") +public class SpotApiController { + + private final SpotFacade spotFacade; + + private final SpotMapper spotMapper; + + @GetMapping("/place/{placeId}") + public ResponseEntity getSpotsInPlace( + @PathVariable("placeId") Long placeId, + @Auth Accessor accessor, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + var result = spotFacade.getSpotSliceInPlace(placeId, accessor, pageRequest); + SpotsInPlaceResponse response = spotMapper.toSpotsInPlaceResponse(result); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/place/{placeId}/me") + @MemberOnly + public ResponseEntity getMySpotInPlace( + @PathVariable("placeId") Long placeId, + @Auth Accessor accessor + ) { + Long memberId = accessor.getMemberId(); + var spotInfo = spotFacade.retrieveMySpotInfoInPlace(placeId, memberId); + var response = spotMapper.toMySpotInPlaceResponse(spotInfo); + return ResponseEntity.ok().body(response); + } + + @PostMapping + @MemberOnly + public ResponseEntity registerSpot( + @ModelAttribute SpotRegisterRequest request, + @Auth final Accessor accessor + ) { + Long memberId = accessor.getMemberId(); + var spotCommand = spotMapper.toRegisterSpotRequest(request); + spotFacade.registerSpot(spotCommand, memberId); + return ResponseEntity.ok().body(new SpotRegisterResponse("Spot 생성 완료")); + } + + @GetMapping("/{spotId}") + public ResponseEntity retrieveSpot(@PathVariable("spotId") Long spotId) { + var spotInfo = spotFacade.retrieveSpotInfo(spotId); + var response = spotMapper.toSpotDetailInfoDto(spotInfo); + return ResponseEntity.ok().body(response); + } + + @PostMapping("/{spotId}") + @MemberOnly + public ResponseEntity updateSpot( + @PathVariable("spotId") Long spotId, + @ModelAttribute SpotUpdateRequest request, + @Auth final Accessor accessor + ) { + Long memberId = accessor.getMemberId(); + var spotCommand = spotMapper.toModifySpotRequest(request); + spotFacade.modifySpot(spotCommand, spotId, memberId); + return ResponseEntity.ok().body(new SpotUpdateResponse("Spot 수정 완료")); + } + + @DeleteMapping("/{spotId}") + @MemberOnly + public ResponseEntity deleteSpot( + @PathVariable("spotId") Long spotId, + @Auth final Accessor accessor + ) { + Long memberId = accessor.getMemberId(); + spotFacade.deleteSpot(spotId, memberId); + return ResponseEntity.ok().body(new SpotDeleteResponse("Spot 삭제 완료")); + } + + @GetMapping("/my") + @MemberOnly + public ResponseEntity getMySpotList( + @Auth final Accessor accessor, + @PageableDefault(size = 5, page = 1) Pageable pageable, + @RequestParam(name = "sortBy", defaultValue = "created_at", required = false) SortBy sortBy, + @RequestParam(name = "sortOrder", defaultValue = "desc", required = false) SortOrder sortOrder, + @RequestParam(name = "selected", defaultValue = "all", required = false) Selected visibility + ) { + Sort.Direction direction = Sort.Direction.fromString(sortOrder.getValue().toUpperCase()); + PageRequest pageRequest = PageRequest.of(pageable.getPageNumber() - 1, + pageable.getPageSize(), + Sort.by(direction, sortBy.getValue())); + final MySpotsResponse mySpotsResponse = spotFacade.getMemberSpotsInfo( + accessor.getMemberId(), visibility, pageRequest); + var response = spotMapper.of(mySpotsResponse); + return ResponseEntity.ok().body(response); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/controller/TravelApiController.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/controller/TravelApiController.java new file mode 100644 index 000000000..41c72526c --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/controller/TravelApiController.java @@ -0,0 +1,59 @@ +package kr.co.yigil.travel.interfaces.controller; + +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.MemberOnly; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.travel.application.TravelFacade; +import kr.co.yigil.travel.interfaces.dto.mapper.TravelMapper; +import kr.co.yigil.travel.interfaces.dto.request.ChangeStatusTravelRequest; +import kr.co.yigil.travel.interfaces.dto.request.TravelsVisibilityChangeRequest; +import kr.co.yigil.travel.interfaces.dto.response.ChangeStatusTravelResponse; +import kr.co.yigil.travel.interfaces.dto.response.TravelsVisibilityChangeResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/travels") +public class TravelApiController { + private final TravelFacade travelFacade; + private final TravelMapper travelMapper; + + @PostMapping("/change-on-public") + @MemberOnly + public ResponseEntity changeOnPublicTravel( + @RequestBody ChangeStatusTravelRequest request, + @Auth final Accessor accessor) + { + travelFacade.changeOnPublicTravel(request.getTravelId(), accessor.getMemberId()); + return ResponseEntity.ok().body(new ChangeStatusTravelResponse("리뷰 공개 상태 변경 완료")); + } + + @PostMapping("/change-on-private") + @MemberOnly + public ResponseEntity changeOnPrivateTravel( + @RequestBody ChangeStatusTravelRequest request, + @Auth final Accessor accessor + ) { + travelFacade.changeOnPrivateTravel(request.getTravelId(), accessor.getMemberId()); + return ResponseEntity.ok().body(new ChangeStatusTravelResponse("리뷰 공개 상태 변경 완료")); + } + + @PostMapping("/change-visibility") + @MemberOnly + public ResponseEntity setTravelsVisibility( + @Auth final Accessor accessor, + @RequestBody TravelsVisibilityChangeRequest request + ){ + var command = travelMapper.of(request); + travelFacade.setTravelsVisibility(accessor.getMemberId(), command); + var response = travelMapper.of("리뷰 공개 상태 변경 완료"); + return ResponseEntity.ok().body(response); + } + + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/CourseDetailInfoDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/CourseDetailInfoDto.java new file mode 100644 index 000000000..776a99333 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/CourseDetailInfoDto.java @@ -0,0 +1,29 @@ +package kr.co.yigil.travel.interfaces.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CourseDetailInfoDto { + private String title; + private String rate; + private String mapStaticImageUrl; + private String description; + List spots; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CourseSpotInfoDto { + private String order; + private String placeName; + private List imageUrlList; + private String rate; + private String description; + private String createDate; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/CourseDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/CourseDto.java new file mode 100644 index 000000000..fb63e8184 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/CourseDto.java @@ -0,0 +1,20 @@ +package kr.co.yigil.travel.interfaces.dto; + +import java.time.LocalDateTime; +import kr.co.yigil.auth.Auth; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class CourseDto { + private Long id; + private String title; + private String mapStaticImageUrl; + private String ownerProfileImageUrl; + private String ownerNickname; + private int spotCount; + private double rate; + private boolean liked; + private LocalDateTime createDate; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/CourseInfoDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/CourseInfoDto.java new file mode 100644 index 000000000..b6215415e --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/CourseInfoDto.java @@ -0,0 +1,18 @@ +package kr.co.yigil.travel.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CourseInfoDto { + private String mapStaticImageFileUrl; + private String title; + private String rate; + private String spotCount; + private String createDate; + private String ownerProfileImageUrl; + private String ownerNickname; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/SpotDetailInfoDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/SpotDetailInfoDto.java new file mode 100644 index 000000000..418e91d69 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/SpotDetailInfoDto.java @@ -0,0 +1,19 @@ +package kr.co.yigil.travel.interfaces.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class SpotDetailInfoDto { + private String placeName; + private String rate; + private String placeAddress; + private String mapStaticImageFileUrl; + private List imageUrls; + private String createDate; + private String description; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/SpotInfoDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/SpotInfoDto.java new file mode 100644 index 000000000..3bd154519 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/SpotInfoDto.java @@ -0,0 +1,28 @@ +package kr.co.yigil.travel.interfaces.dto; + +import java.util.List; +import kr.co.yigil.travel.domain.spot.SpotInfo; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SpotInfoDto { + private Long id; + + private List imageUrlList; + + private String description; + + private String ownerProfileImageUrl; + + private String ownerNickname; + + private String rate; + + private String createDate; + + private boolean liked; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/CourseMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/CourseMapper.java new file mode 100644 index 000000000..b8fa0070b --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/CourseMapper.java @@ -0,0 +1,115 @@ +package kr.co.yigil.travel.interfaces.dto.mapper; + +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.course.CourseCommand; +import kr.co.yigil.travel.domain.course.CourseInfo; +import kr.co.yigil.travel.interfaces.dto.CourseDetailInfoDto; +import kr.co.yigil.travel.interfaces.dto.CourseDto; +import kr.co.yigil.travel.interfaces.dto.CourseInfoDto; +import kr.co.yigil.travel.interfaces.dto.request.CourseRegisterRequest; +import kr.co.yigil.travel.interfaces.dto.request.CourseRegisterWithoutSeriesRequest; +import kr.co.yigil.travel.interfaces.dto.request.CourseUpdateRequest; +import kr.co.yigil.travel.interfaces.dto.response.CourseSearchResponse; +import kr.co.yigil.travel.interfaces.dto.response.CoursesInPlaceResponse; +import kr.co.yigil.travel.interfaces.dto.response.MyCoursesResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; +import org.mapstruct.Named; +import org.mapstruct.factory.Mappers; +import org.springframework.data.domain.Slice; + +@Mapper(componentModel = "spring", uses = {SpotMapper.class}) +public interface CourseMapper { + + CourseMapper INSTANCE = Mappers.getMapper(CourseMapper.class); + + @Mapping(target = "mapStaticImageFileUrl", expression = "java(course.getMapStaticImageFile().getFileUrl())") + @Mapping(target = "title", expression = "java(course.getTitle())") + @Mapping(target = "rate", expression = "java(String.valueOf(course.getRate()))") + @Mapping(target = "spotCount", expression = "java(String.valueOf(course.getSpots().size()))") + @Mapping(target = "createDate", expression = "java(course.getCreatedAt().toString())") + @Mapping(target = "ownerProfileImageUrl", expression = "java(course.getMember().getProfileImageUrl())") + @Mapping(target = "ownerNickname", expression = "java(course.getMember().getNickname())") + CourseInfoDto courseToCourseInfoDto(Course course); + + default List coursesToCourseInfoDtoList(List courses) { + return courses.stream() + .map(this::courseToCourseInfoDto) + .collect(Collectors.toList()); + } + + default CoursesInPlaceResponse courseSliceToCourseInPlaceResponse(Slice courseSlice) { + List courseInfoDtoList = coursesToCourseInfoDtoList(courseSlice.getContent()); + boolean hasNext = courseSlice.hasNext(); + return new CoursesInPlaceResponse(courseInfoDtoList, hasNext); + } + + @Mappings({ + @Mapping(target = "title", source = "title"), + @Mapping(target = "description", source = "description"), + @Mapping(target = "rate", source = "rate"), + @Mapping(target = "isPrivate", source = "private"), + @Mapping(target = "representativeSpotOrder", source = "representativeSpotOrder"), + @Mapping(target = "lineStringJson", source = "lineStringJson"), + @Mapping(target = "mapStaticImageFile", source = "mapStaticImageFile"), + @Mapping(target = "registerSpotRequests", source = "spotRegisterRequests") + }) + CourseCommand.RegisterCourseRequest toRegisterCourseRequest(CourseRegisterRequest request); + + @Mappings({ + @Mapping(target = "title", source = "title"), + @Mapping(target = "description", source = "description"), + @Mapping(target = "rate", source = "rate"), + @Mapping(target = "isPrivate", source = "private"), + @Mapping(target = "representativeSpotOrder", source = "representativeSpotOrder"), + @Mapping(target = "lineStringJson", source = "lineStringJson"), + @Mapping(target = "mapStaticImageFile", source = "mapStaticImageFile"), + @Mapping(target = "spotIds", source = "spotIds") + }) + CourseCommand.RegisterCourseRequestWithSpotInfo toRegisterCourseRequest( + CourseRegisterWithoutSeriesRequest request); + + @Mappings({ + @Mapping(source = "description", target = "description"), + @Mapping(source = "rate", target = "rate"), + @Mapping(source = "spotIdOrder", target = "spotIdOrder"), + @Mapping(source = "courseSpotUpdateRequests", target = "modifySpotRequests") + }) + CourseCommand.ModifyCourseRequest toModifyCourseRequest(CourseUpdateRequest courseUpdateRequest); + + @Mappings({ + @Mapping(source = "title", target = "title"), + @Mapping(source = "rate", target = "rate", qualifiedByName = "doubleToString"), + @Mapping(source = "mapStaticImageUrl", target = "mapStaticImageUrl"), + @Mapping(source = "description", target = "description"), + @Mapping(source = "courseSpotList", target = "spots") + }) + CourseDetailInfoDto toCourseDetailInfoDto(CourseInfo.Main courseInfo); + + @Mappings({ + @Mapping(source = "order", target = "order", qualifiedByName = "intToString"), + @Mapping(source = "placeName", target = "placeName"), + @Mapping(source = "imageUrlList", target = "imageUrlList"), + @Mapping(source = "rate", target = "rate", qualifiedByName = "doubleToString"), + @Mapping(source = "description", target = "description"), + @Mapping(source = "createDate", target = "createDate", qualifiedByName = "localDateTimeToString") + }) + CourseDetailInfoDto.CourseSpotInfoDto toCourseSpotInfoDto(CourseInfo.CourseSpotInfo courseSpotInfo); + + @Named("intToString") + default String intToString(int value) { + return Integer.toString(value); + } + + MyCoursesResponse of (CourseInfo.MyCoursesResponse myCoursesResponse); + MyCoursesResponse.CourseInfo of (CourseInfo.CourseListInfo courseListInfo); + + @Mapping(source = "courses", target = "courses") + CourseSearchResponse toCourseSearchResponse(CourseInfo.Slice slice); + + @Mapping(target = "createDate", source = "createDate") + CourseDto toCourseDto(CourseInfo.CourseSearchInfo courseSearchInfo); +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/MappingUtil.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/MappingUtil.java new file mode 100644 index 000000000..a63a5995d --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/MappingUtil.java @@ -0,0 +1,21 @@ +package kr.co.yigil.travel.interfaces.dto.mapper; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class MappingUtil { + + public static String doubleToString(double value) { + return String.valueOf(value); + } + + public static String intToString(int value) { + return String.valueOf(value); + } + + public static String localDateTimeToString(LocalDateTime dateTime) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + return dateTime.format(formatter); + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/SpotMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/SpotMapper.java new file mode 100644 index 000000000..4462bff3f --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/SpotMapper.java @@ -0,0 +1,120 @@ +package kr.co.yigil.travel.interfaces.dto.mapper; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.spot.SpotCommand; +import kr.co.yigil.travel.domain.spot.SpotInfo; +import kr.co.yigil.travel.domain.spot.SpotInfo.Main; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpotsResponse; +import kr.co.yigil.travel.interfaces.dto.SpotDetailInfoDto; +import kr.co.yigil.travel.interfaces.dto.SpotInfoDto; +import kr.co.yigil.travel.interfaces.dto.request.SpotRegisterRequest; +import kr.co.yigil.travel.interfaces.dto.request.SpotUpdateRequest; +import kr.co.yigil.travel.interfaces.dto.response.MySpotInPlaceResponse; +import kr.co.yigil.travel.interfaces.dto.response.MySpotsResponseDto; +import kr.co.yigil.travel.interfaces.dto.response.SpotsInPlaceResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; +import org.mapstruct.Named; +import org.mapstruct.factory.Mappers; +import org.springframework.data.domain.Slice; + +@Mapper(componentModel = "spring") +public interface SpotMapper { + + SpotMapper INSTANCE = Mappers.getMapper(SpotMapper.class); + + + @Mappings({ + @Mapping(target = "exists", source = "mySpot.exists"), + @Mapping(target = "rate", source = "mySpot.rate", qualifiedByName = "doubleToString"), + @Mapping(target = "imageUrls", source = "mySpot.imageUrls"), + @Mapping(target = "createDate", source = "mySpot.createDate", qualifiedByName = "localDateTimeToString"), + @Mapping(target = "description", source = "mySpot.description") + }) + MySpotInPlaceResponse toMySpotInPlaceResponse(SpotInfo.MySpot mySpot); + + @Mappings({ + @Mapping(source = "placeName", target = "placeName"), + @Mapping(source = "rate", target = "rate", qualifiedByName = "doubleToString"), + @Mapping(source = "placeAddress", target = "placeAddress"), + @Mapping(source = "mapStaticImageFileUrl", target = "mapStaticImageFileUrl"), + @Mapping(source = "imageUrls", target = "imageUrls"), + @Mapping(source = "createDate", target = "createDate", qualifiedByName = "localDateTimeToString"), + @Mapping(source = "description", target = "description") + }) + SpotDetailInfoDto toSpotDetailInfoDto(SpotInfo.Main spotInfoMain); + + @Named("doubleToString") + default String doubleToString(double rate) { + return String.valueOf(rate); + } + + @Named("localDateTimeToString") + default String localDateTimeToString(LocalDateTime createDate) { + return createDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + } + + @Mappings({ + @Mapping(target = "id", source = "id"), + @Mapping(target = "rate", source = "rate"), + @Mapping(target = "description", source = "description"), + @Mapping(target = "originalImages", source = "originalSpotImages"), + @Mapping(target = "updatedImages", source = "updateSpotImages") + }) + SpotCommand.ModifySpotRequest toModifySpotRequest(SpotUpdateRequest request); + + @Mappings({ + @Mapping(target = "registerPlaceRequest.mapStaticImageFile", source = "mapStaticImageFile"), + @Mapping(target = "registerPlaceRequest.placeImageFile", source = "placeImageFile"), + @Mapping(target = "registerPlaceRequest.placeName", source = "placeName"), + @Mapping(target = "registerPlaceRequest.placeAddress", source = "placeAddress"), + @Mapping(target = "registerPlaceRequest.placePointJson", source = "placePointJson"), + @Mapping(target = "files", source = "files"), + @Mapping(target = "pointJson", source = "pointJson"), + @Mapping(target = "title", source = "title"), + @Mapping(target = "description", source = "description"), + @Mapping(target = "rate", source = "rate") + }) + SpotCommand.RegisterSpotRequest toRegisterSpotRequest(SpotRegisterRequest request); + + default SpotCommand.RegisterPlaceRequest toRegisterPlaceRequest(SpotRegisterRequest request) { + return SpotCommand.RegisterPlaceRequest.builder() + .mapStaticImageFile(request.getMapStaticImageFile()) + .placeImageFile(request.getPlaceImageFile()) + .placeName(request.getPlaceName()) + .placeAddress(request.getPlaceAddress()) + .placePointJson(request.getPlacePointJson()) + .build(); + } + + MySpotsResponseDto of(MySpotsResponse mySpotsResponse); + MySpotsResponseDto.SpotInfo of(SpotInfo.SpotListInfo spotInfo); + + @Mappings({ + @Mapping(source = "id", target = "id"), + @Mapping(source = "imageUrls", target = "imageUrlList"), + @Mapping(source = "description", target = "description"), + @Mapping(source = "ownerProfileImageUrl", target = "ownerProfileImageUrl"), + @Mapping(source = "ownerNickname", target = "ownerNickname"), + @Mapping(source = "rate", target = "rate", numberFormat = "#.#"), + @Mapping(source = "createDate", target = "createDate", dateFormat = "yyyy-MM-dd"), + @Mapping(source = "liked", target = "liked") + }) + SpotInfoDto toSpotInfoDto(SpotInfo.Main spotInfoMain); + + default List spotsSliceToSpotInPlaceResponse(List
mains) { + return mains.stream() + .map(this::toSpotInfoDto) + .collect(Collectors.toList()); + } + + default SpotsInPlaceResponse toSpotsInPlaceResponse(SpotInfo.Slice slice) { + List dtos = spotsSliceToSpotInPlaceResponse(slice.getMains()); + return new SpotsInPlaceResponse(dtos, slice.isHasNext()); + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/TravelMapper.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/TravelMapper.java new file mode 100644 index 000000000..9804b0350 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/mapper/TravelMapper.java @@ -0,0 +1,21 @@ +package kr.co.yigil.travel.interfaces.dto.mapper; + +import kr.co.yigil.travel.domain.TravelCommand; +import kr.co.yigil.travel.interfaces.dto.request.TravelsVisibilityChangeRequest; +import kr.co.yigil.travel.interfaces.dto.response.TravelsVisibilityChangeResponse; +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface TravelMapper { + + + TravelCommand.VisibilityChangeRequest of(TravelsVisibilityChangeRequest request); + TravelsVisibilityChangeResponse of(String message); + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/ChangeStatusTravelRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/ChangeStatusTravelRequest.java new file mode 100644 index 000000000..d065e3763 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/ChangeStatusTravelRequest.java @@ -0,0 +1,12 @@ +package kr.co.yigil.travel.interfaces.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ChangeStatusTravelRequest { + Long travelId; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/CourseRegisterRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/CourseRegisterRequest.java new file mode 100644 index 000000000..263e0b010 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/CourseRegisterRequest.java @@ -0,0 +1,23 @@ +package kr.co.yigil.travel.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CourseRegisterRequest { + private String title; + private String description; + private double rate; + private boolean isPrivate; + private int representativeSpotOrder; + private String lineStringJson; + private MultipartFile mapStaticImageFile; + private List spotRegisterRequests; +} + + diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/CourseRegisterWithoutSeriesRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/CourseRegisterWithoutSeriesRequest.java new file mode 100644 index 000000000..7d0af7926 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/CourseRegisterWithoutSeriesRequest.java @@ -0,0 +1,21 @@ +package kr.co.yigil.travel.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CourseRegisterWithoutSeriesRequest { + private String title; + private String description; + private double rate; + private boolean isPrivate; + private int representativeSpotOrder; + private String lineStringJson; + private MultipartFile mapStaticImageFile; + private List spotIds; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/CourseUpdateRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/CourseUpdateRequest.java new file mode 100644 index 000000000..b5c8fac1b --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/CourseUpdateRequest.java @@ -0,0 +1,16 @@ +package kr.co.yigil.travel.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CourseUpdateRequest { + private String description; + private double rate; + private List spotIdOrder; + private List courseSpotUpdateRequests; +} \ No newline at end of file diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/SpotRegisterRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/SpotRegisterRequest.java new file mode 100644 index 000000000..2c03e7158 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/SpotRegisterRequest.java @@ -0,0 +1,24 @@ +package kr.co.yigil.travel.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SpotRegisterRequest { + private String pointJson; + private String title; + private String description; + private List files; + private double rate; + + private MultipartFile mapStaticImageFile; + private MultipartFile placeImageFile; + private String placeName; + private String placeAddress; + private String placePointJson; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/SpotUpdateRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/SpotUpdateRequest.java new file mode 100644 index 000000000..1f6bb574c --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/SpotUpdateRequest.java @@ -0,0 +1,35 @@ +package kr.co.yigil.travel.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SpotUpdateRequest { + private Long id; + private String description; + private double rate; + List originalSpotImages; + List updateSpotImages; + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class OriginalSpotImage { + private String imageUrl; + private int index; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class UpdateSpotImage { + private MultipartFile imageFile; + private int index; + } + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/TravelsVisibilityChangeRequest.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/TravelsVisibilityChangeRequest.java new file mode 100644 index 000000000..c416729bb --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/request/TravelsVisibilityChangeRequest.java @@ -0,0 +1,15 @@ +package kr.co.yigil.travel.interfaces.dto.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TravelsVisibilityChangeRequest { + private List travelIds; + private Boolean isPrivate; + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/ChangeStatusTravelResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/ChangeStatusTravelResponse.java new file mode 100644 index 000000000..446f510c9 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/ChangeStatusTravelResponse.java @@ -0,0 +1,13 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ChangeStatusTravelResponse { + + private String message; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseDeleteResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseDeleteResponse.java new file mode 100644 index 000000000..d5f978862 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseDeleteResponse.java @@ -0,0 +1,13 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CourseDeleteResponse { + private String message; + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseRegisterResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseRegisterResponse.java new file mode 100644 index 000000000..e030cc52c --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseRegisterResponse.java @@ -0,0 +1,12 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CourseRegisterResponse { + private String message; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseSearchResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseSearchResponse.java new file mode 100644 index 000000000..c6f6e65af --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseSearchResponse.java @@ -0,0 +1,13 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.travel.interfaces.dto.CourseDto; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class CourseSearchResponse { + private List courses; + private boolean hasNext; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseUpdateResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseUpdateResponse.java new file mode 100644 index 000000000..4e226802a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CourseUpdateResponse.java @@ -0,0 +1,13 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CourseUpdateResponse { + + private String message; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CoursesInPlaceResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CoursesInPlaceResponse.java new file mode 100644 index 000000000..6d9407eff --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/CoursesInPlaceResponse.java @@ -0,0 +1,15 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.travel.interfaces.dto.CourseInfoDto; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CoursesInPlaceResponse { + private List courses; + private boolean hasNext; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/MyCoursesResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/MyCoursesResponse.java new file mode 100644 index 000000000..4b57afc02 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/MyCoursesResponse.java @@ -0,0 +1,29 @@ +package kr.co.yigil.travel.interfaces.dto.response; + + +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Getter +@Builder +@ToString +public class MyCoursesResponse { + private final List content; + private final int totalPages; + + @Getter + @Builder + @ToString + public static class CourseInfo { + + private final Long courseId; + private final String title; + private final Double rate; + private final Integer spotCount; + private final String createdDate; + private final String mapStaticImageUrl; + private final Boolean isPrivate; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/MySpotInPlaceResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/MySpotInPlaceResponse.java new file mode 100644 index 000000000..7bff23f2f --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/MySpotInPlaceResponse.java @@ -0,0 +1,17 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class MySpotInPlaceResponse { + private boolean exists; + private String rate; + private List imageUrls; + private String createDate; + private String description; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/MySpotsResponseDto.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/MySpotsResponseDto.java new file mode 100644 index 000000000..45b2d421a --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/MySpotsResponseDto.java @@ -0,0 +1,28 @@ +package kr.co.yigil.travel.interfaces.dto.response; + + +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Getter +@Builder +@ToString +public class MySpotsResponseDto { + private List content; + private int totalPages; + + @Getter + @Builder + @ToString + public static class SpotInfo { + + private final Long spotId; + private final String title; + private final double rate; + private final String imageUrl; + private final String createdDate; + private final Boolean isPrivate; + } +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberDeleteResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotDeleteResponse.java similarity index 65% rename from backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberDeleteResponse.java rename to backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotDeleteResponse.java index ff9a22e68..c3a726dcd 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/member/dto/response/MemberDeleteResponse.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotDeleteResponse.java @@ -1,4 +1,4 @@ -package kr.co.yigil.member.dto.response; +package kr.co.yigil.travel.interfaces.dto.response; import lombok.AllArgsConstructor; import lombok.Data; @@ -7,6 +7,6 @@ @Data @AllArgsConstructor @NoArgsConstructor -public class MemberDeleteResponse { +public class SpotDeleteResponse { private String message; } diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotRegisterResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotRegisterResponse.java new file mode 100644 index 000000000..2e019d55b --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotRegisterResponse.java @@ -0,0 +1,14 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SpotRegisterResponse { + String message; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotUpdateResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotUpdateResponse.java new file mode 100644 index 000000000..fed510524 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotUpdateResponse.java @@ -0,0 +1,14 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SpotUpdateResponse { + + private String message; + +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotsInPlaceResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotsInPlaceResponse.java new file mode 100644 index 000000000..8b331540d --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/SpotsInPlaceResponse.java @@ -0,0 +1,15 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import java.util.List; +import kr.co.yigil.travel.interfaces.dto.SpotInfoDto; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SpotsInPlaceResponse { + private List spots; + private boolean hasNext; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/TravelsVisibilityChangeResponse.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/TravelsVisibilityChangeResponse.java new file mode 100644 index 000000000..6340f02a3 --- /dev/null +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/interfaces/dto/response/TravelsVisibilityChangeResponse.java @@ -0,0 +1,10 @@ +package kr.co.yigil.travel.interfaces.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TravelsVisibilityChangeResponse { + private String message; +} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/presentation/CourseController.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/presentation/CourseController.java deleted file mode 100644 index 9f2713e10..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/presentation/CourseController.java +++ /dev/null @@ -1,57 +0,0 @@ -package kr.co.yigil.travel.presentation; - -import kr.co.yigil.auth.Auth; -import kr.co.yigil.auth.MemberOnly; -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.travel.application.CourseService; -import kr.co.yigil.travel.dto.request.CourseCreateRequest; -import kr.co.yigil.travel.dto.request.CourseUpdateRequest; -import kr.co.yigil.travel.dto.response.CourseCreateResponse; -import kr.co.yigil.travel.dto.response.CourseFindResponse; -import kr.co.yigil.travel.dto.response.CourseUpdateResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/courses") -@RequiredArgsConstructor -public class CourseController { - private final CourseService courseService; - - @PostMapping - @MemberOnly - public ResponseEntity createCourse( - @RequestBody CourseCreateRequest courseCreateRequest, - @Auth final Accessor accessor - ){ - CourseCreateResponse courseCreateResponse = courseService.createCourse(accessor.getMemberId(), courseCreateRequest); - return ResponseEntity.ok(courseCreateResponse); - } - - @GetMapping("/{post_id}") - public ResponseEntity findCourse( - @PathVariable("post_id") Long postId - ){ - CourseFindResponse courseFindResponse = courseService.findCourse(postId); - return ResponseEntity.ok().body(courseFindResponse); - } - - @PutMapping("/{post_id}") - @MemberOnly - public ResponseEntity updateCourse( - @PathVariable("post_id") Long postId, - @RequestBody CourseUpdateRequest courseUpdateRequest, - @Auth final Accessor accessor - ){ - CourseUpdateResponse courseUpdateResponse = courseService.updateCourse( postId,accessor.getMemberId(), courseUpdateRequest); - return ResponseEntity.ok().body(courseUpdateResponse); - } - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/presentation/SpotController.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/presentation/SpotController.java deleted file mode 100644 index 34ae85408..000000000 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/presentation/SpotController.java +++ /dev/null @@ -1,70 +0,0 @@ -package kr.co.yigil.travel.presentation; - -import kr.co.yigil.auth.Auth; -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.travel.dto.request.SpotCreateRequest; -import kr.co.yigil.travel.dto.request.SpotUpdateRequest; -import kr.co.yigil.travel.dto.response.SpotCreateResponse; -import kr.co.yigil.travel.dto.response.SpotFindResponse; -import kr.co.yigil.travel.application.SpotService; -import kr.co.yigil.travel.dto.response.SpotUpdateResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/spots") -public class SpotController { - private final SpotService spotService; - - @PostMapping -// @MemberOnly - public ResponseEntity createSpot( - @ModelAttribute SpotCreateRequest spotCreateRequest, - @Auth final Accessor accessor - ){ - SpotCreateResponse spotCreateResponse = spotService.createSpot(accessor.getMemberId(), spotCreateRequest); - return ResponseEntity.ok().body(spotCreateResponse); - } - -// @PostMapping("/course/{courseId}/spot") -// @MemberOnly -// public ResponseEntity createSpotInCourse( -// @PathVariable Long courseId, -// @RequestBody SpotCreateRequest spotRequest, -// @Auth final Accessor accessor -// ){ -// SpotFindResponse spotResponse = spotService.createSpot(accessor.getMemberId(), courseId, spotRequest); -// URI uri = URI.create("api/v1/post/spot/" + spot.getId()); -// return ResponseEntity.created(uri).body(spotResponse); -// } - - @GetMapping("/{post_id}") - public ResponseEntity findSpot( - @PathVariable("post_id") Long postId - ) { - SpotFindResponse post = spotService.findSpotByPostId(postId); - return ResponseEntity.ok().body(post); - } - - // public findAllSpotPost() - 코스에 넣을 spot list - @PostMapping("/{post_id}") -// @MemberOnly - public ResponseEntity updateSpot( - @PathVariable("post_id") Long postId, - @Auth final Accessor accessor, - @ModelAttribute SpotUpdateRequest spotUpdateRequest - ){ -// SpotUpdateResponse spotUpdateResponse = spotService.updateSpot(1L, postId, spotUpdateRequest); - SpotUpdateResponse spotUpdateResponse = spotService.updateSpot(accessor.getMemberId(), postId, spotUpdateRequest); - return ResponseEntity.ok().body(spotUpdateResponse); - } - - -} diff --git a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/util/GeojsonConverter.java b/backend/yigil-api/src/main/java/kr/co/yigil/travel/util/GeojsonConverter.java similarity index 64% rename from backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/util/GeojsonConverter.java rename to backend/yigil-api/src/main/java/kr/co/yigil/travel/util/GeojsonConverter.java index 17298a554..e6bd532cf 100644 --- a/backend/yigil-api/src/main/java/kr/co/yigil/travel/dto/util/GeojsonConverter.java +++ b/backend/yigil-api/src/main/java/kr/co/yigil/travel/util/GeojsonConverter.java @@ -1,49 +1,56 @@ -package kr.co.yigil.travel.dto.util; +package kr.co.yigil.travel.util; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import org.jetbrains.annotations.NotNull; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.Point; import org.locationtech.jts.io.ParseException; import org.locationtech.jts.io.geojson.GeoJsonReader; import org.locationtech.jts.io.geojson.GeoJsonWriter; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; - public class GeojsonConverter { public static LineString convertToLineString(String geoJson) { - GeoJsonReader reader = new GeoJsonReader(); + + GeoJsonReader reader = getGeoJsonReader(); try { - if(reader.read(geoJson) instanceof LineString lineString) + if (reader.read(geoJson) instanceof LineString lineString) { return lineString; - else - throw new BadRequestException(ExceptionCode.INVALID_LINESTRING_GEO_JSON); + } + throw new BadRequestException(ExceptionCode.INVALID_LINESTRING_GEO_JSON); } catch (ParseException e) { throw new BadRequestException(ExceptionCode.INVALID_GEO_JSON_FORMAT); - } catch (ClassCastException e){ + } catch (ClassCastException e) { throw new BadRequestException(ExceptionCode.GEO_JSON_CASTING_ERROR); } } public static Point convertToPoint(String geoJson) { - GeoJsonReader reader = new GeoJsonReader(); + GeoJsonReader reader = getGeoJsonReader(); try { - if(reader.read(geoJson) instanceof Point point) { + if (reader.read(geoJson) instanceof Point point) { return point; - } - else { + } else { throw new BadRequestException(ExceptionCode.INVALID_POINT_GEO_JSON); } } catch (ParseException e) { throw new BadRequestException(ExceptionCode.INVALID_GEO_JSON_FORMAT); - } catch (ClassCastException e){ + } catch (ClassCastException e) { throw new BadRequestException(ExceptionCode.GEO_JSON_CASTING_ERROR); } } + @NotNull + private static GeoJsonReader getGeoJsonReader() { +// PrecisionModel precisionModel = new PrecisionModel(5186); +// GeometryFactory geometryFactory = new GeometryFactory(precisionModel); +// GeoJsonReader reader = new GeoJsonReader(geometryFactory); + GeoJsonReader reader = new GeoJsonReader(); + return reader; + } + public static String convertToJson(Point point) { GeoJsonWriter writer = new GeoJsonWriter(); return writer.write(point); diff --git a/backend/yigil-api/src/main/resources/application.yml b/backend/yigil-api/src/main/resources/application.yml index b47d38a81..c040cc23f 100644 --- a/backend/yigil-api/src/main/resources/application.yml +++ b/backend/yigil-api/src/main/resources/application.yml @@ -2,12 +2,12 @@ spring: servlet: multipart: max-file-size: 50MB - max-request-size: 100MB + max-request-size: 250MB jpa: database: postgresql database-platform: org.hibernate.spatial.dialect.postgis.PostgisPG95Dialect hibernate: - ddl-auto: update + ddl-auto: validate defer-datasource-initialization: true datasource: master: @@ -28,7 +28,8 @@ spring: redis: host: @REDIS_HOST@ port: @REDIS_PORT@ - + jackson: + property-naming-strategy: SNAKE_CASE cloud: aws: @@ -44,11 +45,12 @@ cloud: auto: false server: + port: @YIGIL_API_PORT@ servlet: - session: - cookie: - http-only: true - secure: false + session: + cookie: + http-only: true + secure: false jasypt: encryptor: @@ -60,8 +62,7 @@ logging: slack: webhook-uri: @SLACK_WEBHOOK_URI@ - decorator: datasource: p6spy: - enable-logging: true \ No newline at end of file + enable-logging: true diff --git a/backend/yigil-api/src/main/resources/static/docs/bookmark-api.html b/backend/yigil-api/src/main/resources/static/docs/bookmark-api.html new file mode 100644 index 000000000..f6a510cf5 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/bookmark-api.html @@ -0,0 +1,735 @@ + + + + + + + +BOOKMARK API + + + + + +
+
+

BOOKMARK API

+
+
+

북마크 추가

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 1. /api/v1/add-bookmark/{place_id}
ParameterDescription

place_id

북마크할 장소 아이디

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/add-bookmark/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 50
+
+{
+  "message" : "장소 북마크 추가 성공"
+}
+
+
+
+
+
+
+

북마크 삭제

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 2. /api/v1/delete-bookmark/{place_id}
ParameterDescription

place_id

북마크 취소할 장소 아이디

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/delete-bookmark/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 50
+
+{
+  "message" : "장소 북마크 제거 성공"
+}
+
+
+
+
+
+
+

북마크 조회

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Query Parameters
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - created_at(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/bookmarks HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

bookmarks

Array

Bookmark의 정보

bookmarks[].place_id

Number

Bookmark한 장소 아이디

bookmarks[].place_name

String

Bookmark한 장소 이름

bookmarks[].place_image

String

Bookmark한 장소 이미지 URL

bookmarks[].rate

Number

Bookmark한 장소 평점

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 153
+
+{
+  "bookmarks" : [ {
+    "place_id" : 1,
+    "place_name" : "placeName",
+    "place_image" : "placeImage",
+    "rate" : 5.0
+  } ],
+  "has_next" : true
+}
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/comment-api.html b/backend/yigil-api/src/main/resources/static/docs/comment-api.html new file mode 100644 index 000000000..1f13bb1be --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/comment-api.html @@ -0,0 +1,1012 @@ + + + + + + + +COMMENT API + + + + + +
+
+

COMMENT API

+
+
+

comment 생성

+
+

Request

+
+

로그인 필수 : N

+
+
+
+
{
+  "content" : "content",
+  "parentId" : 1
+}
+
+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/comments/travels/1 HTTP/1.1
+Content-Type: application/json
+Content-Length: 45
+Host: spring.restdocs.test
+
+{
+  "content" : "content",
+  "parentId" : 1
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "댓글 생성 성공"
+}
+
+
+
+
+
+
+

댓글 리스트 조회

+
+

Request

+
+

로그인 필수 : N

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 1. /api/v1/comments/travels/{travelId}
ParameterDescription

travelId

본문 id

+ ++++ + + + + + + + + + + + + + + + + +
ParameterDescription

page

페이지 번호

size

페이지 크기

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/comments/travels/1?page=1&size=5 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content

Array

comment의 정보

content[].id

Number

댓글 id

content[].content

String

댓글 내용

content[].member_id

Number

회원 id

content[].member_nickname

String

닉네임

content[].member_image_url

String

프로필 이미지 url

content[].child_count

Number

자식 댓글 수

content[].created_at

String

생성일

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 497
+
+{
+  "content" : [ {
+    "id" : 1,
+    "content" : "content",
+    "member_id" : 1,
+    "member_nickname" : "nickname",
+    "member_image_url" : "http://yigil.co.kr/images/profile.jpg",
+    "child_count" : 3,
+    "created_at" : "2024-03-01"
+  }, {
+    "id" : 2,
+    "content" : "content2",
+    "member_id" : 1,
+    "member_nickname" : "nickname3",
+    "member_image_url" : "http://yigil.co.kr/images/profile3.jpg",
+    "child_count" : 1,
+    "created_at" : "2024-03-01"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

대댓글 리스트 조회

+
+

Request

+
+

로그인 필수 : N

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 2. /api/v1/comments/parents/{parentId}
ParameterDescription

parentId

부모 댓글 id

+ ++++ + + + + + + + + + + + + + + + + +
ParameterDescription

page

페이지 번호

size

페이지 크기

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/comments/parents/1?page=1&size=5 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content

Array

comment의 정보

content[].id

Number

댓글 id

content[].content

String

댓글 내용

content[].member_id

Number

회원 id

content[].member_nickname

String

닉네임

content[].member_image_url

String

프로필 이미지

content[].child_count

Number

자식 댓글 수

content[].created_at

String

생성일

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 497
+
+{
+  "content" : [ {
+    "id" : 1,
+    "content" : "content",
+    "member_id" : 1,
+    "member_nickname" : "nickname",
+    "member_image_url" : "http://yigil.co.kr/images/profile.jpg",
+    "child_count" : 0,
+    "created_at" : "2024-03-01"
+  }, {
+    "id" : 2,
+    "content" : "content2",
+    "member_id" : 1,
+    "member_nickname" : "nickname3",
+    "member_image_url" : "http://yigil.co.kr/images/profile3.jpg",
+    "child_count" : 0,
+    "created_at" : "2024-03-01"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

comment 삭제

+
+

Request

+
+

로그인 필수 : Y

+
+ + ++++ + + + + + + + + + + + + +
Table 3. /api/v1/comments/{commentId}
ParameterDescription

commentId

댓글 id

+
+
+
+
+
+
+
HTTP Request 예시
+
+
+
DELETE /api/v1/comments/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "댓글 삭제 성공"
+}
+
+
+
+
+
+
+

comment 수정

+
+

Request

+
+

로그인 필수 : Y

+
+ + ++++ + + + + + + + + + + + + +
Table 4. /api/v1/comments/{commentId}
ParameterDescription

commentId

댓글 id

+
+
+
{
+  "content" : "content"
+}
+
+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/comments/1 HTTP/1.1
+Content-Type: application/json
+Content-Length: 27
+Host: spring.restdocs.test
+
+{
+  "content" : "content"
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "댓글 수정 성공"
+}
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/course-api.html b/backend/yigil-api/src/main/resources/static/docs/course-api.html new file mode 100644 index 000000000..cfebf03f0 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/course-api.html @@ -0,0 +1,1688 @@ + + + + + + + +COURSE API + + + + + +
+
+

COURSE API

+
+
+

장소 내 Course 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : N

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 1. /api/v1/courses/place/{placeId}
ParameterDescription

placeId

장소 아이디

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - created_at(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/courses/place/1?page=1&size=5&sortBy=created_at&sortOrder=desc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

courses

Array

course의 정보

courses[].map_static_image_file_url

String

코스의 지도 정보를 나타내는 이미지 파일 경로

courses[].title

String

코스의 제목

courses[].rate

String

코스의 평점 정보

courses[].spot_count

String

코스 내부 장소의 개수

courses[].create_date

String

코스의 생성 일자

courses[].owner_profile_image_url

String

코스 생성자의 프로필 이미지 경로

courses[].owner_nickname

String

코스 생성자의 닉네임 정보

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 306
+
+{
+  "courses" : [ {
+    "map_static_image_file_url" : "images/static.img",
+    "title" : "코스이름",
+    "rate" : "5.0",
+    "spot_count" : "3",
+    "create_date" : "2024-02-01",
+    "owner_profile_image_url" : "images/owner.jpg",
+    "owner_nickname" : "코스 작성자"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

Course 신규 등록

+
+

링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

title

String

코스의 제목

description

String

코스의 본문

rate

Number

코스의 평점

isPrivate

Boolean

코스의 공개 여부

representativeSpotOrder

Number

코스의 대표 스팟 순서 번호

lineStringJson

String

코스의 라인 스트링 정보

spotRegisterRequests

Array

코스 내 스팟의 정보

spotRegisterRequests[].pointJson

String

스팟의 포인트 정보

spotRegisterRequests[].title

String

스팟의 제목

spotRegisterRequests[].description

String

스팟의 본문

spotRegisterRequests[].rate

Number

스팟의 평점

spotRegisterRequests[].placeName

String

스팟의 장소명

spotRegisterRequests[].placeAddress

String

스팟의 장소 주소

spotRegisterRequests[].placePointJson

String

스팟의 장소 포인트 정보

+
+

로그인 필수 : Y

+
+
+
Request Part
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
PartDescription

mapStaticImageFile

Course의 위치 정보를 나타내는 지도 이미지 파일

spotRegisterRequests[0].files

스팟 관련 이미지 파일

spotRegisterRequests[0].mapStaticImageFile

스팟의 위치 정보를 나타내는 지도 이미지 파일

spotRegisterRequests[0].placeImageFile

스팟의 장소 이미지 파일

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/courses HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 366
+Host: spring.restdocs.test
+
+{
+  "title" : "test",
+  "description" : "test",
+  "rate" : 4.5,
+  "isPrivate" : false,
+  "representativeSpotOrder" : 1,
+  "lineStringJson" : "test",
+  "spotRegisterRequests" : [ {
+    "pointJson" : "test",
+    "title" : "test",
+    "description" : "test",
+    "rate" : 4.5,
+    "placeName" : "test",
+    "placeAddress" : "test",
+    "placePointJson" : "test"
+  } ]
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "Course 생성 완료"
+}
+
+
+
+
+
+
+

Course 신규 등록 (이미 등록된 Spot)

+
+

링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

title

String

코스의 제목

description

String

코스의 본문

rate

Number

코스의 평점

isPrivate

Boolean

코스의 공개 여부

representativeSpotOrder

Number

코스의 대표 스팟 순서 번호

lineStringJson

String

코스의 라인 스트링 정보

spotIds

Array

코스 내 스팟의 아이디 배열

+
+

로그인 필수 : Y

+
+
+
Request Part
+ ++++ + + + + + + + + + + + + +
PartDescription

mapStaticImageFile

Course의 위치 정보를 나타내는 지도 이미지 파일

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/courses/only HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 173
+Host: spring.restdocs.test
+
+{
+  "title" : "test",
+  "description" : "test",
+  "rate" : 4.5,
+  "isPrivate" : false,
+  "representativeSpotOrder" : 1,
+  "lineStringJson" : "test",
+  "spotIds" : [ 1, 2 ]
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "Course 생성 완료"
+}
+
+
+
+
+
+
+

Course 상세 정보 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : N

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 2. /api/v1/courses/{courseId}
ParameterDescription

courseId

코스 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/courses/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

title

String

코스의 제목

rate

String

코스의 평점

map_static_image_url

String

코스의 위치를 나타내는 지도 이미지 경로

description

String

코스의 본문

spots

Array

코스 내 스팟의 정보

spots[].order

String

코스 내 현재 스팟의 순서

spots[].place_name

String

코스 내 스팟의 장소명

spots[].image_url_list

Array

코스 내 스팟 관련 이미지의 경로 배열

spots[].rate

String

코스 내 스팟의 평점 정보

spots[].description

String

코스 내 스팟의 본문 정보

spots[].create_date

String

코스 내 스팟의 생성 일자

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 370
+
+{
+  "title" : "최고의 코스",
+  "rate" : "4.5",
+  "map_static_image_url" : "images/static.png",
+  "description" : "코스의 본문",
+  "spots" : [ {
+    "order" : "1",
+    "place_name" : "장소명",
+    "image_url_list" : [ "images/spot.jpg", "images/spotted.png" ],
+    "rate" : "4.5",
+    "description" : "스팟 본문",
+    "create_date" : "2024-02-01"
+  } ]
+}
+
+
+
+
+
+
+

Course 업데이트

+
+

링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

description

String

코스의 본문

rate

Number

코스의 평점

spotIdOrder

Array

코스 내 스팟의 아이디 배열

courseSpotUpdateRequests

Array

코스 내 스팟의 정보

courseSpotUpdateRequests[].id

Number

스팟의 아이디

courseSpotUpdateRequests[].description

String

스팟의 본문

courseSpotUpdateRequests[].rate

Number

스팟의 평점

courseSpotUpdateRequests[].originalSpotImages

Array

스팟의 기존 이미지 정보

courseSpotUpdateRequests[].updateSpotImages

Array

스팟의 업데이트 이미지 정보

courseSpotUpdateRequests[].originalSpotImages[].imageUrl

String

스팟의 기존 이미지 경로

courseSpotUpdateRequests[].originalSpotImages[].index

Number

스팟의 기존 이미지 인덱스

courseSpotUpdateRequests[].updateSpotImages[].index

Number

스팟의 업데이트 이미지 인덱스

+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 3. /api/v1/courses/{courseId}
ParameterDescription

courseId

코스 아이디

+
+
+
+

Request Part

+ ++++ + + + + + + + + + + + + +
PartDescription

courseSpotUpdateRequests[0].updateSpotImages[0].imageFile

스팟 관련 이미지 파일

+
+
HTTP Request 예시
+
+
+
POST /api/v1/courses/1 HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 325
+Host: spring.restdocs.test
+
+{
+  "description" : "test",
+  "rate" : 4.5,
+  "spotIdOrder" : [ 1, 2 ],
+  "courseSpotUpdateRequests" : [ {
+    "id" : 1,
+    "description" : "test",
+    "rate" : 4.5,
+    "originalSpotImages" : [ {
+      "imageUrl" : "images/spot.jpg",
+      "index" : 1
+    } ],
+    "updateSpotImages" : [ {
+      "index" : 1
+    } ]
+  } ]
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "Course 수정 완료"
+}
+
+
+
+
+
+
+

Course 삭제

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 4. /api/v1/courses/{courseId}
ParameterDescription

courseId

코스 아이디

+
+
+
HTTP Request 예시
+
+
+
DELETE /api/v1/courses/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "Course 삭제 완료"
+}
+
+
+
+
+
+
+

My Course 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - createdAt(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

selected

필터 기능 - all(디폴트값) 전체공개 / private 비공개

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/courses/my?page=1&size=5&sortBy=created_at&sortOrder=desc&selected=public HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].course_id

Number

코스 ID

content[].title

String

코스 제목

content[].rate

Number

코스 평점

content[].spot_count

Number

코스 포함 장소 수

content[].created_date

String

코스 생성일

content[].map_static_image_url

String

코스 지도 이미지 URL

content[].is_private

Boolean

공개여부

total_pages

Number

총 페이지 수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 246
+
+{
+  "content" : [ {
+    "course_id" : 1,
+    "title" : "test course",
+    "rate" : 4.5,
+    "spot_count" : 10,
+    "created_date" : "2024-01-01",
+    "map_static_image_url" : "images/map.jpg",
+    "is_private" : false
+  } ],
+  "total_pages" : 1
+}
+
+
+
+
+
+
+

장소명으로 코스 검색

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : N

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

keyword

검색 키워드

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - created_at(디폴트값) / rate / name

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/courses/search?keyword=keyword&page=1&size=5&sortBy=created_at&sortOrder=desc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

courses

Array

course의 정보

courses[].id

Number

코스 ID

courses[].title

String

코스 제목

courses[].map_static_image_url

String

코스 지도 이미지 URL

courses[].owner_profile_image_url

String

코스 작성자 프로필 이미지 URL

courses[].owner_nickname

String

코스 작성자 닉네임

courses[].spot_count

Number

코스 포함 장소 수

courses[].rate

Number

코스 평점

courses[].liked

Boolean

코스 좋아요 여부

courses[].create_date

String

코스 생성일

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 324
+
+{
+  "courses" : [ {
+    "id" : 1,
+    "title" : "title",
+    "map_static_image_url" : "mapStatic.jpg",
+    "owner_profile_image_url" : "profile.png",
+    "owner_nickname" : "nickname",
+    "spot_count" : 3,
+    "rate" : 4.5,
+    "liked" : false,
+    "create_date" : "2024-03-08T20:36:36.397742"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/favor-api.html b/backend/yigil-api/src/main/resources/static/docs/favor-api.html new file mode 100644 index 000000000..a5f4d0418 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/favor-api.html @@ -0,0 +1,615 @@ + + + + + + + +FAVOR API + + + + + +
+
+

FAVOR API

+
+
+

TRAVEL 좋아요 하기

+
+

Request

+
+
HTTP Request 예시
+
+
+
POST /api/v1/like/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
Path parameters
+ + ++++ + + + + + + + + + + + + +
Table 1. /api/v1/like/{travelId}
ParameterDescription

travelId

좋아요를 추가할 게시물의 ID

+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

좋아요가 완료되었습니다.

+
+
+

Response Body

+
+
+
{
+  "message" : "좋아요가 완료되었습니다."
+}
+
+
+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 55
+
+{
+  "message" : "좋아요가 완료되었습니다."
+}
+
+
+
+
+
+
+
+

TRAVEL 좋아요 취소하기

+
+

Request

+
+
HTTP Request 예시
+
+
+
POST /api/v1/unlike/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
Path parameters
+ + ++++ + + + + + + + + + + + + +
Table 2. /api/v1/unlike/{travelId}
ParameterDescription

travelId

좋아요를 취소할 게시물의 ID

+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

좋아요가 취소되었습니다.

+
+
+

Response Body

+
+
+
{
+  "message" : "좋아요가 취소되었습니다."
+}
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/follow-api.html b/backend/yigil-api/src/main/resources/static/docs/follow-api.html new file mode 100644 index 000000000..454615522 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/follow-api.html @@ -0,0 +1,999 @@ + + + + + + + +FOLLOW API + + + + + +
+
+

FOLLOW API

+
+
+

팔로우

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 1. /api/v1/follows/follow/{member_id}
ParameterDescription

member_id

팔로우할 회원 아이디

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/follows/follow/2 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 36
+
+{
+  "message" : "팔로우 성공"
+}
+
+
+
+
+
+
+

언팔로우

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 2. /api/v1/follows/unfollow/{member_id}
ParameterDescription

member_id

언팔로우할 회원 아이디

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/follows/unfollow/2 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 39
+
+{
+  "message" : "언팔로우 성공"
+}
+
+
+
+
+
+
+

내 팔로잉 리스트

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지

size

페이지 크기

sortBy

정렬 옵션

sortOrder

정렬 순서

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/follows/followings?page=1&size=5&sortBy=id&sortOrder=asc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].member_id

Number

회원 ID

content[].nickname

String

닉네임

content[].profile_image_url

String

프로필 이미지 URL

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 171
+
+{
+  "content" : [ {
+    "member_id" : 1,
+    "nickname" : "test user",
+    "profile_image_url" : "https://cdn.yigil.co.kr/images/profile.jpg"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+
+

내 팔로워 리스트

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지

size

페이지 크기

sortBy

정렬 옵션

sortOrder

정렬 순서

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/follows/followers?page=1&size=5&sortBy=id&sortOrder=asc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].member_id

Number

회원 ID

content[].nickname

String

닉네임

content[].profile_image_url

String

프로필 이미지 URL

content[].following

Boolean

팔로우 여부

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 195
+
+{
+  "content" : [ {
+    "member_id" : 1,
+    "nickname" : "test user",
+    "profile_image_url" : "https://cdn.yigil.co.kr/images/profile.jpg",
+    "following" : true
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

멤버의 팔로잉 리스트

+
+

Request

+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/follows/1/followings?page=1&size=5&sortBy=id&sortOrder=asc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].member_id

Number

회원 ID

content[].nickname

String

닉네임

content[].profile_image_url

String

프로필 이미지 URL

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 178
+
+{
+  "content" : [ {
+    "member_id" : 1,
+    "nickname" : "test user",
+    "profile_image_url" : "https://cdn.yigil.co.kr/images/images/profile.jpg"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+
+

멤버의 팔로워 리스트

+
+

Request

+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/follows/1/followers?page=1&size=5&sortBy=id&sortOrder=asc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].member_id

Number

회원 ID

content[].nickname

String

닉네임

content[].profile_image_url

String

프로필 이미지 URL

content[].following

Boolean

팔로우 여부

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 195
+
+{
+  "content" : [ {
+    "member_id" : 1,
+    "nickname" : "test user",
+    "profile_image_url" : "https://cdn.yigil.co.kr/images/profile.jpg",
+    "following" : true
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/index.html b/backend/yigil-api/src/main/resources/static/docs/index.html new file mode 100644 index 000000000..d734ebd40 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/index.html @@ -0,0 +1,6852 @@ + + + + + + + +API Document + + + + + + + +
+
+

SPOT API

+
+
+

장소 내 Spot 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : N

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 1. /api/v1/spots/place/{placeId}
ParameterDescription

placeId

장소 아이디

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - created_at(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/spots/place/1?page=1&size=5&sortBy=created_at&sortOrder=desc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

spots

Array

spot의 정보

spots[].id

Number

Spot의 고유 아이디

spots[].image_url_list

Array

imageUrl의 List

spots[].description

String

Spot의 설명

spots[].owner_profile_image_url

String

Spot 등록 사용자의 프로필 이미지 Url

spots[].owner_nickname

String

Spot 등록 사용자의 닉네임

spots[].rate

String

Spot의 평점

spots[].create_date

String

Spot의 생성일시

spots[].liked

Boolean

로그인한 사용자의 좋아요 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 328
+
+{
+  "spots" : [ {
+    "id" : 1,
+    "image_url_list" : [ "images/image.png", "images/photo.jpeg" ],
+    "description" : "설명",
+    "owner_profile_image_url" : "images/profile.jpg",
+    "owner_nickname" : "오너 닉네임",
+    "rate" : "4.5",
+    "create_date" : "2024-02-01",
+    "liked" : true
+  } ],
+  "has_next" : true
+}
+
+
+
+
+
+
+

장소 내 내가 작성한 Spot 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 2. /api/v1/spots/place/{placeId}/me
ParameterDescription

placeId

장소 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/spots/place/1/me HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

exists

Boolean

스팟이 존재하는지 여부

rate

String

스팟의 평점 정보

image_urls

Array

스팟 관련 이미지의 url 배열

create_date

String

스팟의 생성 일자

description

String

스팟의 본문

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 183
+
+{
+  "exists" : true,
+  "rate" : "4.5",
+  "image_urls" : [ "images/image.jpg", "images/thumb.png" ],
+  "create_date" : "2024-02-05",
+  "description" : "내가 쓴 리뷰리뷰리뷰"
+}
+
+
+
+
+
+
+

Spot 신규 등록

+
+

노션 링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

pointJson

String

스팟의 위치를 나타내는 geojson

title

String

스팟의 제목

description

String

스팟의 본문

rate

Number

스팟 관련 평점 정보

placeName

String

스팟 관련 장소 명

placeAddress

String

스팟 관련 장소 주소

placePointJson

String

스팟 관련 장소의 위치를 나타내는 geojson

+
+

로그인 필수 : Y

+
+
+
Request Parts
+ ++++ + + + + + + + + + + + + + + + + + + + + +
PartDescription

files

Spot의 이미지 파일 (다중파일)

mapStaticImageFile

Spot의 장소를 나타내는 지도 이미지 파일(필수x)

placeImageFile

Spot의 장소를 나타내는 썸네일 이미지 파일(필수x)

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/spots HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 330
+Host: spring.restdocs.test
+
+{
+  "pointJson" : "{ \"type\" : \"Point\", \"coordinates\": [ 555,  555 ] }",
+  "title" : "스팟 타이틀",
+  "description" : "스팟 본문",
+  "rate" : 5.0,
+  "placeName" : "장소 타이틀",
+  "placeAddress" : "장소구 장소면 장소리",
+  "placePointJson" : "{ \"type\" : \"Point\", \"coordinates\": [ 555,  555 ] }"
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 38
+
+{
+  "message" : "Spot 생성 완료"
+}
+
+
+
+
+
+
+

Spot 상세 정보 조회

+
+

Request

+
+
+
GET /api/v1/spots/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+

로그인 필수 : N

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 3. /api/v1/spots/{spotId}
ParameterDescription

spotId

스팟 아이디

+
+
+
HTTP Request 예시
+
+
+
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

place_name

String

스팟 관련 장소 명

rate

String

스팟의 평점 정보

place_address

String

스팟 관련 장소의 주소

map_static_image_file_url

String

스팟의 위치를 나타내는 이미지 파일의 상대경로

image_urls

Array

스팟 관련 이미지의 상대 경로 배열

create_date

String

스팟의 생성 일자

description

String

스팟의 본문 정보

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 286
+
+{
+  "place_name" : "장소명",
+  "rate" : "3.0",
+  "place_address" : "장소시 장소구 장소동",
+  "map_static_image_file_url" : "images/mapstatic.png",
+  "image_urls" : [ "images/spot.png", "images/spot.jpeg" ],
+  "create_date" : "2024-02-01",
+  "description" : "스팟 설명"
+}
+
+
+
+
+
+
+

Spot 수정

+
+

링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

id

Number

스팟 아이디

description

String

스팟의 본문 정보

rate

Number

스팟의 평점 정보

originalSpotImages

Array

기존 스팟 이미지 정보

originalSpotImages[].imageUrl

String

기존 스팟 이미지의 url

originalSpotImages[].index

Number

기존 스팟 이미지의 index

updateSpotImages

Array

업데이트 할 스팟 이미지 정보

updateSpotImages[].index

Number

업데이트 할 스팟 이미지의 index

+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 4. /api/v1/spots/{spotId}
ParameterDescription

spotId

스팟 아이디

+
+
+
Request Parts
+ ++++ + + + + + + + + + + + + +
PartDescription

updateSpotImages[0].imageFile

업데이트 할 스팟의 새로운 이미지 파일

+
+
+
HTTP Request 예시
+
+
+
{
+  "id" : 1,
+  "description" : "스팟 설명",
+  "rate" : 4.5,
+  "originalSpotImages" : [ {
+    "imageUrl" : "images/spot.jpg",
+    "index" : 0
+  } ],
+  "updateSpotImages" : [ {
+    "index" : 0
+  } ]
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 38
+
+{
+  "message" : "Spot 수정 완료"
+}
+
+
+
+
+
+
+

Spot 삭제

+
+

Request

+
+
+
DELETE /api/v1/spots/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 5. /api/v1/spots/{spotId}
ParameterDescription

spotId

스팟 아이디

+
+
+
HTTP Request 예시
+
+
+
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 38
+
+{
+  "message" : "Spot 삭제 완료"
+}
+
+
+
+
+
+
+

내 Spot 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - createdAt(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

selected

필터 기능 - all(디폴트값) 전체공개 / private 비공개

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/spots/my?page=1&size=5&sortBy=created_at&sortOrder=desc&selected=public HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].spot_id

Number

장소 ID

content[].title

String

장소 제목

content[].rate

Number

장소 평점

content[].image_url

String

장소 이미지 URL

content[].created_date

String

장소 생성일

content[].is_private

Boolean

공개여부

total_pages

Number

총 페이지 수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 210
+
+{
+  "content" : [ {
+    "spot_id" : 1,
+    "title" : "test course",
+    "rate" : 4.5,
+    "image_url" : "images/map.jpg",
+    "created_date" : "2024-01-01",
+    "is_private" : false
+  } ],
+  "total_pages" : 1
+}
+
+
+
+
+
+
+
+
+
+

COURSE API

+
+
+

장소 내 Course 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : N

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 6. /api/v1/courses/place/{placeId}
ParameterDescription

placeId

장소 아이디

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - created_at(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/courses/place/1?page=1&size=5&sortBy=created_at&sortOrder=desc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

courses

Array

course의 정보

courses[].map_static_image_file_url

String

코스의 지도 정보를 나타내는 이미지 파일 경로

courses[].title

String

코스의 제목

courses[].rate

String

코스의 평점 정보

courses[].spot_count

String

코스 내부 장소의 개수

courses[].create_date

String

코스의 생성 일자

courses[].owner_profile_image_url

String

코스 생성자의 프로필 이미지 경로

courses[].owner_nickname

String

코스 생성자의 닉네임 정보

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 306
+
+{
+  "courses" : [ {
+    "map_static_image_file_url" : "images/static.img",
+    "title" : "코스이름",
+    "rate" : "5.0",
+    "spot_count" : "3",
+    "create_date" : "2024-02-01",
+    "owner_profile_image_url" : "images/owner.jpg",
+    "owner_nickname" : "코스 작성자"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

Course 신규 등록

+
+

링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

title

String

코스의 제목

description

String

코스의 본문

rate

Number

코스의 평점

isPrivate

Boolean

코스의 공개 여부

representativeSpotOrder

Number

코스의 대표 스팟 순서 번호

lineStringJson

String

코스의 라인 스트링 정보

spotRegisterRequests

Array

코스 내 스팟의 정보

spotRegisterRequests[].pointJson

String

스팟의 포인트 정보

spotRegisterRequests[].title

String

스팟의 제목

spotRegisterRequests[].description

String

스팟의 본문

spotRegisterRequests[].rate

Number

스팟의 평점

spotRegisterRequests[].placeName

String

스팟의 장소명

spotRegisterRequests[].placeAddress

String

스팟의 장소 주소

spotRegisterRequests[].placePointJson

String

스팟의 장소 포인트 정보

+
+

로그인 필수 : Y

+
+
+
Request Part
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
PartDescription

mapStaticImageFile

Course의 위치 정보를 나타내는 지도 이미지 파일

spotRegisterRequests[0].files

스팟 관련 이미지 파일

spotRegisterRequests[0].mapStaticImageFile

스팟의 위치 정보를 나타내는 지도 이미지 파일

spotRegisterRequests[0].placeImageFile

스팟의 장소 이미지 파일

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/courses HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 366
+Host: spring.restdocs.test
+
+{
+  "title" : "test",
+  "description" : "test",
+  "rate" : 4.5,
+  "isPrivate" : false,
+  "representativeSpotOrder" : 1,
+  "lineStringJson" : "test",
+  "spotRegisterRequests" : [ {
+    "pointJson" : "test",
+    "title" : "test",
+    "description" : "test",
+    "rate" : 4.5,
+    "placeName" : "test",
+    "placeAddress" : "test",
+    "placePointJson" : "test"
+  } ]
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "Course 생성 완료"
+}
+
+
+
+
+
+
+

Course 신규 등록 (이미 등록된 Spot)

+
+

링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

title

String

코스의 제목

description

String

코스의 본문

rate

Number

코스의 평점

isPrivate

Boolean

코스의 공개 여부

representativeSpotOrder

Number

코스의 대표 스팟 순서 번호

lineStringJson

String

코스의 라인 스트링 정보

spotIds

Array

코스 내 스팟의 아이디 배열

+
+

로그인 필수 : Y

+
+
+
Request Part
+ ++++ + + + + + + + + + + + + +
PartDescription

mapStaticImageFile

Course의 위치 정보를 나타내는 지도 이미지 파일

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/courses/only HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 173
+Host: spring.restdocs.test
+
+{
+  "title" : "test",
+  "description" : "test",
+  "rate" : 4.5,
+  "isPrivate" : false,
+  "representativeSpotOrder" : 1,
+  "lineStringJson" : "test",
+  "spotIds" : [ 1, 2 ]
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "Course 생성 완료"
+}
+
+
+
+
+
+
+

Course 상세 정보 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : N

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 7. /api/v1/courses/{courseId}
ParameterDescription

courseId

코스 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/courses/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

title

String

코스의 제목

rate

String

코스의 평점

map_static_image_url

String

코스의 위치를 나타내는 지도 이미지 경로

description

String

코스의 본문

spots

Array

코스 내 스팟의 정보

spots[].order

String

코스 내 현재 스팟의 순서

spots[].place_name

String

코스 내 스팟의 장소명

spots[].image_url_list

Array

코스 내 스팟 관련 이미지의 경로 배열

spots[].rate

String

코스 내 스팟의 평점 정보

spots[].description

String

코스 내 스팟의 본문 정보

spots[].create_date

String

코스 내 스팟의 생성 일자

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 370
+
+{
+  "title" : "최고의 코스",
+  "rate" : "4.5",
+  "map_static_image_url" : "images/static.png",
+  "description" : "코스의 본문",
+  "spots" : [ {
+    "order" : "1",
+    "place_name" : "장소명",
+    "image_url_list" : [ "images/spot.jpg", "images/spotted.png" ],
+    "rate" : "4.5",
+    "description" : "스팟 본문",
+    "create_date" : "2024-02-01"
+  } ]
+}
+
+
+
+
+
+
+

Course 업데이트

+
+

링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

description

String

코스의 본문

rate

Number

코스의 평점

spotIdOrder

Array

코스 내 스팟의 아이디 배열

courseSpotUpdateRequests

Array

코스 내 스팟의 정보

courseSpotUpdateRequests[].id

Number

스팟의 아이디

courseSpotUpdateRequests[].description

String

스팟의 본문

courseSpotUpdateRequests[].rate

Number

스팟의 평점

courseSpotUpdateRequests[].originalSpotImages

Array

스팟의 기존 이미지 정보

courseSpotUpdateRequests[].updateSpotImages

Array

스팟의 업데이트 이미지 정보

courseSpotUpdateRequests[].originalSpotImages[].imageUrl

String

스팟의 기존 이미지 경로

courseSpotUpdateRequests[].originalSpotImages[].index

Number

스팟의 기존 이미지 인덱스

courseSpotUpdateRequests[].updateSpotImages[].index

Number

스팟의 업데이트 이미지 인덱스

+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 8. /api/v1/courses/{courseId}
ParameterDescription

courseId

코스 아이디

+
+
+
+

Request Part

+ ++++ + + + + + + + + + + + + +
PartDescription

courseSpotUpdateRequests[0].updateSpotImages[0].imageFile

스팟 관련 이미지 파일

+
+
HTTP Request 예시
+
+
+
POST /api/v1/courses/1 HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 325
+Host: spring.restdocs.test
+
+{
+  "description" : "test",
+  "rate" : 4.5,
+  "spotIdOrder" : [ 1, 2 ],
+  "courseSpotUpdateRequests" : [ {
+    "id" : 1,
+    "description" : "test",
+    "rate" : 4.5,
+    "originalSpotImages" : [ {
+      "imageUrl" : "images/spot.jpg",
+      "index" : 1
+    } ],
+    "updateSpotImages" : [ {
+      "index" : 1
+    } ]
+  } ]
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "Course 수정 완료"
+}
+
+
+
+
+
+
+

Course 삭제

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 9. /api/v1/courses/{courseId}
ParameterDescription

courseId

코스 아이디

+
+
+
HTTP Request 예시
+
+
+
DELETE /api/v1/courses/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "Course 삭제 완료"
+}
+
+
+
+
+
+
+

My Course 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - createdAt(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

selected

필터 기능 - all(디폴트값) 전체공개 / private 비공개

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/courses/my?page=1&size=5&sortBy=created_at&sortOrder=desc&selected=public HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].course_id

Number

코스 ID

content[].title

String

코스 제목

content[].rate

Number

코스 평점

content[].spot_count

Number

코스 포함 장소 수

content[].created_date

String

코스 생성일

content[].map_static_image_url

String

코스 지도 이미지 URL

content[].is_private

Boolean

공개여부

total_pages

Number

총 페이지 수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 246
+
+{
+  "content" : [ {
+    "course_id" : 1,
+    "title" : "test course",
+    "rate" : 4.5,
+    "spot_count" : 10,
+    "created_date" : "2024-01-01",
+    "map_static_image_url" : "images/map.jpg",
+    "is_private" : false
+  } ],
+  "total_pages" : 1
+}
+
+
+
+
+
+
+

장소명으로 코스 검색

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : N

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

keyword

검색 키워드

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - created_at(디폴트값) / rate / name

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/courses/search?keyword=keyword&page=1&size=5&sortBy=created_at&sortOrder=desc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

courses

Array

course의 정보

courses[].id

Number

코스 ID

courses[].title

String

코스 제목

courses[].map_static_image_url

String

코스 지도 이미지 URL

courses[].owner_profile_image_url

String

코스 작성자 프로필 이미지 URL

courses[].owner_nickname

String

코스 작성자 닉네임

courses[].spot_count

Number

코스 포함 장소 수

courses[].rate

Number

코스 평점

courses[].liked

Boolean

코스 좋아요 여부

courses[].create_date

String

코스 생성일

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 324
+
+{
+  "courses" : [ {
+    "id" : 1,
+    "title" : "title",
+    "map_static_image_url" : "mapStatic.jpg",
+    "owner_profile_image_url" : "profile.png",
+    "owner_nickname" : "nickname",
+    "spot_count" : 3,
+    "rate" : 4.5,
+    "liked" : false,
+    "create_date" : "2024-03-09T00:00:23.776163"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+
+
+

Travel API

+
+
+

게시글 공개 상태로 전환

+
+

Request

+
+
+
{
+  "travel_id" : 1
+}
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/travels/change-on-public HTTP/1.1
+Content-Type: application/json
+Content-Length: 21
+Host: spring.restdocs.test
+
+{
+  "travel_id" : 1
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 54
+
+{
+  "message" : "리뷰 공개 상태 변경 완료"
+}
+
+
+
+
+
+
+

게시글 비공개 상태로 전환

+
+

Request

+
+
+
{
+  "travel_id" : 2
+}
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/travels/change-on-private HTTP/1.1
+Content-Type: application/json
+Content-Length: 21
+Host: spring.restdocs.test
+
+{
+  "travel_id" : 2
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 54
+
+{
+  "message" : "리뷰 공개 상태 변경 완료"
+}
+
+
+
+
+
+
+

게시글 리스트 공개/비공개 전환

+
+

Request

+
+
+
{
+  "travel_ids" : [ 1 ],
+  "is_private" : false
+}
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/travels/change-visibility HTTP/1.1
+Content-Type: application/json
+Content-Length: 50
+Host: spring.restdocs.test
+
+{
+  "travel_ids" : [ 1 ],
+  "is_private" : false
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 54
+
+{
+  "message" : "리뷰 공개 상태 변경 완료"
+}
+
+
+
+
+
+
+
+
+

Place API

+
+
+

Static Image 존재 유무 확인

+
+

Request

+
+
+
+
+
+
+

로그인 필수: Y

+
+
+
Query Parameters
+ ++++ + + + + + + + + + + + + + + + + +
ParameterDescription

name

찾고있는 장소의 이름

address

찾고있는 장소의 주소

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/static-image?name=%EC%96%B4%EB%8A%90%EC%9E%A5%EC%86%8C&address=%EC%96%B4%EB%8A%90%EC%A3%BC%EC%86%8C HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

exists

Boolean

해당 파일이 존재하는지 여부

map_static_image_url

String

찾은 파일의 이미지 Url

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 70
+
+{
+  "exists" : true,
+  "map_static_image_url" : "http://yigil.co.kr"
+}
+
+
+
+
+
+
+

인기 장소 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/popular HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

인기 장소 목록 더보기

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/popular/more HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

장소 상세 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 10. /api/v1/places/{placeId}
ParameterDescription

placeId

장소의 고유 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

id

Number

장소의 고유 아이디

place_name

String

장소의 장소명

address

String

장소의 주소

thumbnail_image_url

String

장소의 대표 이미지 URL

map_static_image_url

String

장소의 지도 이미지 URL

bookmarked

Boolean

유저의 장소 북마크 여부

rate

Number

장소의 평점

review_count

Number

장소 내의 리뷰 개수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 215
+
+{
+  "id" : 1,
+  "place_name" : "장소명",
+  "address" : "장소주소",
+  "thumbnail_image_url" : "image.com",
+  "map_static_image_url" : "image.net",
+  "rate" : 3.0,
+  "review_count" : 50,
+  "bookmarked" : true
+}
+
+
+
+
+
+
+

지역별 장소 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 11. /api/v1/places/region/{regionId}
ParameterDescription

regionId

지역의 고유 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/region/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

지역별 장소 목록 더보기

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 12. /api/v1/places/region/{regionId}/more
ParameterDescription

regionId

지역의 고유 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/region/1/more HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

주변 장소 검색

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
Query Parameters
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

minX

최소 x 좌표

minY

최소 y 좌표

maxX

최대 x 좌표

maxY

최대 y 좌표

page

페이지 번호

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/near?minX=1&minY=1&maxX=2&maxY=2&page=1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

주변 장소의 정보

places[].id

Number

장소의 고유 아이디

places[].place_name

String

장소의 이름

places[].x

Number

장소의 x 좌표

places[].y

Number

장소의 y 좌표

current_page

Number

현재 페이지 번호

total_pages

Number

총 페이지의 개수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 147
+
+{
+  "places" : [ {
+    "id" : 1,
+    "x" : 127.0,
+    "y" : 38.0,
+    "place_name" : "장소명"
+  } ],
+  "current_page" : 1,
+  "total_pages" : 1
+}
+
+
+
+
+
+
+

개인별 추천 장소 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/popular-demographics HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

개인별 추천 장소 더보기

+
+

Request

+
+
+
+
+
+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/popular-demographics-more HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

추천 검색어 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
+

Query Parameters

+ ++++ + + + + + + + + + + + + +
ParameterDescription

keyword

검색하고자 하는 키워드

+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/keyword?keyword=%ED%82%A4%EC%9B%8C%EB%93%9C HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

keywords[]

Array

추천 키워드의 이름

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 34
+
+{
+  "keywords" : [ "키워드" ]
+}
+
+
+
+
+
+
+

장소 검색

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
+

Query Parameters

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

keyword

검색하고자 하는 키워드

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - latest_uploaded_time(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/search?keyword=%ED%82%A4%EC%9B%8C%EB%93%9C&page=1&size=5&sortBy=latest_uploaded_time&sortOrder=desc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 212
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ],
+  "has_next" : true
+}
+
+
+
+
+
+
+
+
+

REGION API

+
+
+

관심 장소 선택을 위한 정보 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/regions/select HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

categories

Array

지역별 카테고리 정보

categories[].category_name

String

지역별 카테고리 명

categories[].regions

Array

카테고리 내 지역 정보

categories[].regions[].id

Number

지역의 고유 id

categories[].regions[].region_name

String

지역명

categories[].regions[].selected

Boolean

사용자의 해당 지역의 관심지역 설정 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 178
+
+{
+  "categories" : [ {
+    "category_name" : "서울 북부",
+    "regions" : [ {
+      "id" : 1,
+      "region_name" : "홍대 | 와플",
+      "selected" : true
+    } ]
+  } ]
+}
+
+
+
+
+
+
+

사용자의 관심 지역 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/regions/my HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

regions

Array

지역 정보

regions[].id

Number

지역의 고유 Id

regions[].name

String

지역의 이름

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 72
+
+{
+  "regions" : [ {
+    "id" : 1,
+    "name" : "홍대 | 상수"
+  } ]
+}
+
+
+
+
+
+
+
+
+

BOOKMARK API

+
+
+

북마크 추가

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 13. /api/v1/add-bookmark/{place_id}
ParameterDescription

place_id

북마크할 장소 아이디

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/add-bookmark/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 50
+
+{
+  "message" : "장소 북마크 추가 성공"
+}
+
+
+
+
+
+
+

북마크 삭제

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 14. /api/v1/delete-bookmark/{place_id}
ParameterDescription

place_id

북마크 취소할 장소 아이디

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/delete-bookmark/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 50
+
+{
+  "message" : "장소 북마크 제거 성공"
+}
+
+
+
+
+
+
+

북마크 조회

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Query Parameters
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - created_at(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/bookmarks HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

bookmarks

Array

Bookmark의 정보

bookmarks[].place_id

Number

Bookmark한 장소 아이디

bookmarks[].place_name

String

Bookmark한 장소 이름

bookmarks[].place_image

String

Bookmark한 장소 이미지 URL

bookmarks[].rate

Number

Bookmark한 장소 평점

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 153
+
+{
+  "bookmarks" : [ {
+    "place_id" : 1,
+    "place_name" : "placeName",
+    "place_image" : "placeImage",
+    "rate" : 5.0
+  } ],
+  "has_next" : true
+}
+
+
+
+
+
+
+
+
+

FAVOR API

+
+
+

TRAVEL 좋아요 하기

+
+

Request

+
+
HTTP Request 예시
+
+
+
POST /api/v1/like/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
Path parameters
+ + ++++ + + + + + + + + + + + + +
Table 15. /api/v1/like/{travelId}
ParameterDescription

travelId

좋아요를 추가할 게시물의 ID

+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

좋아요가 완료되었습니다.

+
+
+

Response Body

+
+
+
{
+  "message" : "좋아요가 완료되었습니다."
+}
+
+
+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 55
+
+{
+  "message" : "좋아요가 완료되었습니다."
+}
+
+
+
+
+
+
+
+

TRAVEL 좋아요 취소하기

+
+

Request

+
+
HTTP Request 예시
+
+
+
POST /api/v1/unlike/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
Path parameters
+ + ++++ + + + + + + + + + + + + +
Table 16. /api/v1/unlike/{travelId}
ParameterDescription

travelId

좋아요를 취소할 게시물의 ID

+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

좋아요가 취소되었습니다.

+
+
+

Response Body

+
+
+
{
+  "message" : "좋아요가 취소되었습니다."
+}
+
+
+
+
+
+
+
+

COMMENT API

+
+
+

comment 생성

+
+

Request

+
+

로그인 필수 : N

+
+
+
+
{
+  "content" : "content",
+  "parentId" : 1
+}
+
+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/comments/travels/1 HTTP/1.1
+Content-Type: application/json
+Content-Length: 45
+Host: spring.restdocs.test
+
+{
+  "content" : "content",
+  "parentId" : 1
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "댓글 생성 성공"
+}
+
+
+
+
+
+
+

댓글 리스트 조회

+
+

Request

+
+

로그인 필수 : N

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 17. /api/v1/comments/travels/{travelId}
ParameterDescription

travelId

본문 id

+ ++++ + + + + + + + + + + + + + + + + +
ParameterDescription

page

페이지 번호

size

페이지 크기

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/comments/travels/1?page=1&size=5 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content

Array

comment의 정보

content[].id

Number

댓글 id

content[].content

String

댓글 내용

content[].member_id

Number

회원 id

content[].member_nickname

String

닉네임

content[].member_image_url

String

프로필 이미지 url

content[].child_count

Number

자식 댓글 수

content[].created_at

String

생성일

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 497
+
+{
+  "content" : [ {
+    "id" : 1,
+    "content" : "content",
+    "member_id" : 1,
+    "member_nickname" : "nickname",
+    "member_image_url" : "http://yigil.co.kr/images/profile.jpg",
+    "child_count" : 3,
+    "created_at" : "2024-03-01"
+  }, {
+    "id" : 2,
+    "content" : "content2",
+    "member_id" : 1,
+    "member_nickname" : "nickname3",
+    "member_image_url" : "http://yigil.co.kr/images/profile3.jpg",
+    "child_count" : 1,
+    "created_at" : "2024-03-01"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

대댓글 리스트 조회

+
+

Request

+
+

로그인 필수 : N

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 18. /api/v1/comments/parents/{parentId}
ParameterDescription

parentId

부모 댓글 id

+ ++++ + + + + + + + + + + + + + + + + +
ParameterDescription

page

페이지 번호

size

페이지 크기

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/comments/parents/1?page=1&size=5 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content

Array

comment의 정보

content[].id

Number

댓글 id

content[].content

String

댓글 내용

content[].member_id

Number

회원 id

content[].member_nickname

String

닉네임

content[].member_image_url

String

프로필 이미지

content[].child_count

Number

자식 댓글 수

content[].created_at

String

생성일

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 497
+
+{
+  "content" : [ {
+    "id" : 1,
+    "content" : "content",
+    "member_id" : 1,
+    "member_nickname" : "nickname",
+    "member_image_url" : "http://yigil.co.kr/images/profile.jpg",
+    "child_count" : 0,
+    "created_at" : "2024-03-01"
+  }, {
+    "id" : 2,
+    "content" : "content2",
+    "member_id" : 1,
+    "member_nickname" : "nickname3",
+    "member_image_url" : "http://yigil.co.kr/images/profile3.jpg",
+    "child_count" : 0,
+    "created_at" : "2024-03-01"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

comment 삭제

+
+

Request

+
+

로그인 필수 : Y

+
+ + ++++ + + + + + + + + + + + + +
Table 19. /api/v1/comments/{commentId}
ParameterDescription

commentId

댓글 id

+
+
+
+
+
+
+
HTTP Request 예시
+
+
+
DELETE /api/v1/comments/1 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "댓글 삭제 성공"
+}
+
+
+
+
+
+
+

comment 수정

+
+

Request

+
+

로그인 필수 : Y

+
+ + ++++ + + + + + + + + + + + + +
Table 20. /api/v1/comments/{commentId}
ParameterDescription

commentId

댓글 id

+
+
+
{
+  "content" : "content"
+}
+
+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/comments/1 HTTP/1.1
+Content-Type: application/json
+Content-Length: 27
+Host: spring.restdocs.test
+
+{
+  "content" : "content"
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "댓글 수정 성공"
+}
+
+
+
+
+
+
+
+
+

FOLLOW API

+
+
+

팔로우

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 21. /api/v1/follows/follow/{member_id}
ParameterDescription

member_id

팔로우할 회원 아이디

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/follows/follow/2 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 36
+
+{
+  "message" : "팔로우 성공"
+}
+
+
+
+
+
+
+

언팔로우

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 22. /api/v1/follows/unfollow/{member_id}
ParameterDescription

member_id

언팔로우할 회원 아이디

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/follows/unfollow/2 HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 39
+
+{
+  "message" : "언팔로우 성공"
+}
+
+
+
+
+
+
+

내 팔로잉 리스트

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지

size

페이지 크기

sortBy

정렬 옵션

sortOrder

정렬 순서

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/follows/followings?page=1&size=5&sortBy=id&sortOrder=asc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].member_id

Number

회원 ID

content[].nickname

String

닉네임

content[].profile_image_url

String

프로필 이미지 URL

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 171
+
+{
+  "content" : [ {
+    "member_id" : 1,
+    "nickname" : "test user",
+    "profile_image_url" : "https://cdn.yigil.co.kr/images/profile.jpg"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+
+

내 팔로워 리스트

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Path parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지

size

페이지 크기

sortBy

정렬 옵션

sortOrder

정렬 순서

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/follows/followers?page=1&size=5&sortBy=id&sortOrder=asc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].member_id

Number

회원 ID

content[].nickname

String

닉네임

content[].profile_image_url

String

프로필 이미지 URL

content[].following

Boolean

팔로우 여부

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 195
+
+{
+  "content" : [ {
+    "member_id" : 1,
+    "nickname" : "test user",
+    "profile_image_url" : "https://cdn.yigil.co.kr/images/profile.jpg",
+    "following" : true
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

멤버의 팔로잉 리스트

+
+

Request

+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/follows/1/followings?page=1&size=5&sortBy=id&sortOrder=asc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].member_id

Number

회원 ID

content[].nickname

String

닉네임

content[].profile_image_url

String

프로필 이미지 URL

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 178
+
+{
+  "content" : [ {
+    "member_id" : 1,
+    "nickname" : "test user",
+    "profile_image_url" : "https://cdn.yigil.co.kr/images/images/profile.jpg"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+
+

멤버의 팔로워 리스트

+
+

Request

+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/follows/1/followers?page=1&size=5&sortBy=id&sortOrder=asc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].member_id

Number

회원 ID

content[].nickname

String

닉네임

content[].profile_image_url

String

프로필 이미지 URL

content[].following

Boolean

팔로우 여부

has_next

Boolean

다음 페이지 존재 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 195
+
+{
+  "content" : [ {
+    "member_id" : 1,
+    "nickname" : "test user",
+    "profile_image_url" : "https://cdn.yigil.co.kr/images/profile.jpg",
+    "following" : true
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+
+
+
+

LOGIN API

+
+
+

Login

+
+

Request

+
+

Login required: Y

+
+
+
HTTP Request Example
+
+
+
POST /api/v1/login HTTP/1.1
+Content-Type: application/json
+Authorization: Bearer mockAccessToken
+Content-Length: 111
+Host: localhost:8080
+
+{"id":123, "nickname":"TestUser", "profileImageUrl":"test.jpg", "email":"test@example.com", "provider":"kakao"}
+
+
+
+
+
Request Headers
+ ++++ + + + + + + + + + + + + +
NameDescription

Authorization

인증 토큰

+
+
+
Request Fields
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

id

Number

사용자 ID

nickname

String

사용자 닉네임

profileImageUrl

String

사용자 프로필 이미지 URL

email

String

사용자 이메일

provider

String

인증 제공자

+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

로그인 성공 메시지

+
+
HTTP Response Example
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 30
+
+{"message":"로그인 성공"}
+
+
+
+
+
+
+

Logout

+
+

Request

+
+

Login required: Y

+
+
+
HTTP Request Example
+
+
+
GET /api/v1/logout HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

로그아웃 성공 메시지

+
+
HTTP Response Example
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 33
+
+{"message":"로그아웃 성공"}
+
+
+
+
+
+
+
+
+

MEMBER API

+
+
+

내 정보 조회

+
+

Request

+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/members HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

member_id

Number

회원 ID

email

String

이메일

nickname

String

닉네임

profile_image_url

String

프로필 이미지 URL

favorite_regions

Array

좋아하는 지역리스트

favorite_regions[].id

Number

좋아하는 지역 ID

favorite_regions[].name

String

좋아하는 지역 이름

following_count

Number

팔로잉 수

follower_count

Number

팔로워 수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 314
+
+{
+  "member_id" : 1,
+  "email" : "test@yigil.co.kr",
+  "nickname" : "test user",
+  "profile_image_url" : "https://cdn.igil.co.kr/images/profile.jpg",
+  "favorite_regions" : [ {
+    "id" : 1,
+    "name" : "서울"
+  }, {
+    "id" : 2,
+    "name" : "경기"
+  } ],
+  "following_count" : 20,
+  "follower_count" : 10
+}
+
+
+
+
+
+
+
+

내 정보 수정

+
+

노션 링크:

+
+
+

Request

+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/members HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 108
+Host: spring.restdocs.test
+
+{
+  "nickname" : "nickname",
+  "age" : "10대",
+  "gender" : "남성",
+  "favoriteRegionIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+
Request Parts
+ ++++ + + + + + + + + + + + + +
PartDescription

profileImageFile

프로필 이미지 파일

+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 53
+
+{
+  "message" : "회원 정보 업데이트 성공"
+}
+
+
+
+
+
+
+
+

탈퇴

+
+

Request

+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
DELETE /api/v1/members HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "회원 탈퇴 성공"
+}
+
+
+
+
+
+
+
+

회원 정보 조회

+
+

로그인 필수: N

+
+
+

Request

+
+
HTTP Request 예시
+
+
+
GET /api/v1/members/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

member_id

Number

회원 ID

email

String

이메일

nickname

String

닉네임

profile_image_url

String

프로필 이미지 URL

favorite_regions

Array

좋아하는 지역리스트

favorite_regions[].id

Number

좋아하는 지역 ID

favorite_regions[].name

String

좋아하는 지역 이름

following_count

Number

팔로잉 수

follower_count

Number

팔로워 수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 315
+
+{
+  "member_id" : 1,
+  "email" : "test@yigil.co.kr",
+  "nickname" : "test user",
+  "profile_image_url" : "https://cdn.yigil.co.kr/images/profile.jpg",
+  "favorite_regions" : [ {
+    "id" : 1,
+    "name" : "서울"
+  }, {
+    "id" : 2,
+    "name" : "경기"
+  } ],
+  "following_count" : 20,
+  "follower_count" : 10
+}
+
+
+
+
+
+
+
+

닉네임 중복 체크

+
+

로그인 필수: N

+
+
+

Request

+
+
HTTP Request 예시
+
+
+
POST /api/v1/members/nickname_duplicate_check HTTP/1.1
+Content-Type: application/json
+Content-Length: 29
+Host: spring.restdocs.test
+
+{
+  "nickname" : "nickname"
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

available

Boolean

사용 가능 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 24
+
+{
+  "available" : true
+}
+
+
+
+
+
+
+
+
+
+

NOTIFICATION API

+
+
+

알림 조회

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Query Parameters
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지

size

페이지 크기

sortBy

정렬 옵션

sortOrder

정렬 순서

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/notifications HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

notifications

Array

notification의 정보

notifications[].message

String

Notification의 메시지

notifications[].create_date

String

Notification의 생성일시

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 115
+
+{
+  "notifications" : [ {
+    "message" : "message",
+    "create_date" : "createDate"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

알림 스트림

+
+

Request

+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/notifications/stream HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: text/event-stream
+Content-Length: 524
+
+id:1
+event:test event
+data:{"id":null,"member":{"id":1,"email":"email","social_login_id":"12345678","nickname":"nickname","profile_image_url":"http://cdn.yigil.co.kr/image.jpg","status":"ACTIVE","social_login_type":"KAKAO","gender":"NONE","ages":"NONE","favorite_regions":[],"joined_at":"2024-03-09T00:00:20.987842","modified_at":"2024-03-09T00:00:20.987844","favorite_region_ids":[]},"message":"새로운 알림입니다.","created_at":"2024-03-09T00:00:20.987845","modified_at":"2024-03-09T00:00:20.987846","read":false}
+
+
+
+
+
+
+
+
+

REGION API

+
+
+

관심 장소 선택을 위한 정보 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/regions/select HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

categories

Array

지역별 카테고리 정보

categories[].category_name

String

지역별 카테고리 명

categories[].regions

Array

카테고리 내 지역 정보

categories[].regions[].id

Number

지역의 고유 id

categories[].regions[].region_name

String

지역명

categories[].regions[].selected

Boolean

사용자의 해당 지역의 관심지역 설정 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 178
+
+{
+  "categories" : [ {
+    "category_name" : "서울 북부",
+    "regions" : [ {
+      "id" : 1,
+      "region_name" : "홍대 | 와플",
+      "selected" : true
+    } ]
+  } ]
+}
+
+
+
+
+
+
+

사용자의 관심 지역 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/regions/my HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

regions

Array

지역 정보

regions[].id

Number

지역의 고유 Id

regions[].name

String

지역의 이름

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 72
+
+{
+  "regions" : [ {
+    "id" : 1,
+    "name" : "홍대 | 상수"
+  } ]
+}
+
+
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/login-api.html b/backend/yigil-api/src/main/resources/static/docs/login-api.html new file mode 100644 index 000000000..6dd424c37 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/login-api.html @@ -0,0 +1,634 @@ + + + + + + + +LOGIN API + + + + + +
+
+

LOGIN API

+
+
+

Login

+
+

Request

+
+

Login required: Y

+
+
+
HTTP Request Example
+
+
+
POST /api/v1/login HTTP/1.1
+Content-Type: application/json
+Authorization: Bearer mockAccessToken
+Content-Length: 111
+Host: localhost:8080
+
+{"id":123, "nickname":"TestUser", "profileImageUrl":"test.jpg", "email":"test@example.com", "provider":"kakao"}
+
+
+
+
+
Request Headers
+ ++++ + + + + + + + + + + + + +
NameDescription

Authorization

인증 토큰

+
+
+
Request Fields
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

id

Number

사용자 ID

nickname

String

사용자 닉네임

profileImageUrl

String

사용자 프로필 이미지 URL

email

String

사용자 이메일

provider

String

인증 제공자

+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

로그인 성공 메시지

+
+
HTTP Response Example
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 30
+
+{"message":"로그인 성공"}
+
+
+
+
+
+
+

Logout

+
+

Request

+
+

Login required: Y

+
+
+
HTTP Request Example
+
+
+
GET /api/v1/logout HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

로그아웃 성공 메시지

+
+
HTTP Response Example
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 33
+
+{"message":"로그아웃 성공"}
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/member-api.html b/backend/yigil-api/src/main/resources/static/docs/member-api.html new file mode 100644 index 000000000..523c5cf4e --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/member-api.html @@ -0,0 +1,884 @@ + + + + + + + +MEMBER API + + + + + +
+
+

MEMBER API

+
+
+

내 정보 조회

+
+

Request

+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/members HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

member_id

Number

회원 ID

email

String

이메일

nickname

String

닉네임

profile_image_url

String

프로필 이미지 URL

favorite_regions

Array

좋아하는 지역리스트

favorite_regions[].id

Number

좋아하는 지역 ID

favorite_regions[].name

String

좋아하는 지역 이름

following_count

Number

팔로잉 수

follower_count

Number

팔로워 수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 314
+
+{
+  "member_id" : 1,
+  "email" : "test@yigil.co.kr",
+  "nickname" : "test user",
+  "profile_image_url" : "https://cdn.igil.co.kr/images/profile.jpg",
+  "favorite_regions" : [ {
+    "id" : 1,
+    "name" : "서울"
+  }, {
+    "id" : 2,
+    "name" : "경기"
+  } ],
+  "following_count" : 20,
+  "follower_count" : 10
+}
+
+
+
+
+
+
+
+

내 정보 수정

+
+

노션 링크:

+
+
+

Request

+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/members HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 108
+Host: spring.restdocs.test
+
+{
+  "nickname" : "nickname",
+  "age" : "10대",
+  "gender" : "남성",
+  "favoriteRegionIds" : [ 1, 2, 3 ]
+}
+
+
+
+
+
Request Parts
+ ++++ + + + + + + + + + + + + +
PartDescription

profileImageFile

프로필 이미지 파일

+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 53
+
+{
+  "message" : "회원 정보 업데이트 성공"
+}
+
+
+
+
+
+
+
+

탈퇴

+
+

Request

+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
DELETE /api/v1/members HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 40
+
+{
+  "message" : "회원 탈퇴 성공"
+}
+
+
+
+
+
+
+
+

회원 정보 조회

+
+

로그인 필수: N

+
+
+

Request

+
+
HTTP Request 예시
+
+
+
GET /api/v1/members/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

member_id

Number

회원 ID

email

String

이메일

nickname

String

닉네임

profile_image_url

String

프로필 이미지 URL

favorite_regions

Array

좋아하는 지역리스트

favorite_regions[].id

Number

좋아하는 지역 ID

favorite_regions[].name

String

좋아하는 지역 이름

following_count

Number

팔로잉 수

follower_count

Number

팔로워 수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 315
+
+{
+  "member_id" : 1,
+  "email" : "test@yigil.co.kr",
+  "nickname" : "test user",
+  "profile_image_url" : "https://cdn.yigil.co.kr/images/profile.jpg",
+  "favorite_regions" : [ {
+    "id" : 1,
+    "name" : "서울"
+  }, {
+    "id" : 2,
+    "name" : "경기"
+  } ],
+  "following_count" : 20,
+  "follower_count" : 10
+}
+
+
+
+
+
+
+
+

닉네임 중복 체크

+
+

로그인 필수: N

+
+
+

Request

+
+
HTTP Request 예시
+
+
+
POST /api/v1/members/nickname_duplicate_check HTTP/1.1
+Content-Type: application/json
+Content-Length: 29
+Host: spring.restdocs.test
+
+{
+  "nickname" : "nickname"
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

available

Boolean

사용 가능 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 24
+
+{
+  "available" : true
+}
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/notification-api.html b/backend/yigil-api/src/main/resources/static/docs/notification-api.html new file mode 100644 index 000000000..71de12924 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/notification-api.html @@ -0,0 +1,599 @@ + + + + + + + +NOTIFICATION API + + + + + +
+
+

NOTIFICATION API

+
+
+

알림 조회

+
+

Request

+
+

로그인 필수 : Y

+
+
+
Query Parameters
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지

size

페이지 크기

sortBy

정렬 옵션

sortOrder

정렬 순서

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/notifications HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

notifications

Array

notification의 정보

notifications[].message

String

Notification의 메시지

notifications[].create_date

String

Notification의 생성일시

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 115
+
+{
+  "notifications" : [ {
+    "message" : "message",
+    "create_date" : "createDate"
+  } ],
+  "has_next" : false
+}
+
+
+
+
+
+
+

알림 스트림

+
+

Request

+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/notifications/stream HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: text/event-stream
+Content-Length: 523
+
+id:1
+event:test event
+data:{"id":null,"member":{"id":1,"email":"email","social_login_id":"12345678","nickname":"nickname","profile_image_url":"http://cdn.yigil.co.kr/image.jpg","status":"ACTIVE","social_login_type":"KAKAO","gender":"NONE","ages":"NONE","favorite_regions":[],"joined_at":"2024-03-08T20:36:33.584317","modified_at":"2024-03-08T20:36:33.584319","favorite_region_ids":[]},"message":"새로운 알림입니다.","created_at":"2024-03-08T20:36:33.58432","modified_at":"2024-03-08T20:36:33.584321","read":false}
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/place-api.html b/backend/yigil-api/src/main/resources/static/docs/place-api.html new file mode 100644 index 000000000..8af16a4cd --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/place-api.html @@ -0,0 +1,1663 @@ + + + + + + + +Place API + + + + + +
+
+

Place API

+
+
+

Static Image 존재 유무 확인

+
+

Request

+
+
+
+
+
+
+

로그인 필수: Y

+
+
+
Query Parameters
+ ++++ + + + + + + + + + + + + + + + + +
ParameterDescription

name

찾고있는 장소의 이름

address

찾고있는 장소의 주소

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/static-image?name=%EC%96%B4%EB%8A%90%EC%9E%A5%EC%86%8C&address=%EC%96%B4%EB%8A%90%EC%A3%BC%EC%86%8C HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

exists

Boolean

해당 파일이 존재하는지 여부

map_static_image_url

String

찾은 파일의 이미지 Url

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 70
+
+{
+  "exists" : true,
+  "map_static_image_url" : "http://yigil.co.kr"
+}
+
+
+
+
+
+
+

인기 장소 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/popular HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

인기 장소 목록 더보기

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/popular/more HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

장소 상세 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 1. /api/v1/places/{placeId}
ParameterDescription

placeId

장소의 고유 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

id

Number

장소의 고유 아이디

place_name

String

장소의 장소명

address

String

장소의 주소

thumbnail_image_url

String

장소의 대표 이미지 URL

map_static_image_url

String

장소의 지도 이미지 URL

bookmarked

Boolean

유저의 장소 북마크 여부

rate

Number

장소의 평점

review_count

Number

장소 내의 리뷰 개수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 215
+
+{
+  "id" : 1,
+  "place_name" : "장소명",
+  "address" : "장소주소",
+  "thumbnail_image_url" : "image.com",
+  "map_static_image_url" : "image.net",
+  "rate" : 3.0,
+  "review_count" : 50,
+  "bookmarked" : true
+}
+
+
+
+
+
+
+

지역별 장소 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 2. /api/v1/places/region/{regionId}
ParameterDescription

regionId

지역의 고유 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/region/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

지역별 장소 목록 더보기

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
Path Parameters
+ + ++++ + + + + + + + + + + + + +
Table 3. /api/v1/places/region/{regionId}/more
ParameterDescription

regionId

지역의 고유 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/region/1/more HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

주변 장소 검색

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
Query Parameters
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

minX

최소 x 좌표

minY

최소 y 좌표

maxX

최대 x 좌표

maxY

최대 y 좌표

page

페이지 번호

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/near?minX=1&minY=1&maxX=2&maxY=2&page=1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

주변 장소의 정보

places[].id

Number

장소의 고유 아이디

places[].place_name

String

장소의 이름

places[].x

Number

장소의 x 좌표

places[].y

Number

장소의 y 좌표

current_page

Number

현재 페이지 번호

total_pages

Number

총 페이지의 개수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 147
+
+{
+  "places" : [ {
+    "id" : 1,
+    "x" : 127.0,
+    "y" : 38.0,
+    "place_name" : "장소명"
+  } ],
+  "current_page" : 1,
+  "total_pages" : 1
+}
+
+
+
+
+
+
+

개인별 추천 장소 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/popular-demographics HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

개인별 추천 장소 더보기

+
+

Request

+
+
+
+
+
+
+

로그인 필수: Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/popular-demographics-more HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 191
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ]
+}
+
+
+
+
+
+
+

추천 검색어 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
+

Query Parameters

+ ++++ + + + + + + + + + + + + +
ParameterDescription

keyword

검색하고자 하는 키워드

+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/keyword?keyword=%ED%82%A4%EC%9B%8C%EB%93%9C HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

keywords[]

Array

추천 키워드의 이름

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 34
+
+{
+  "keywords" : [ "키워드" ]
+}
+
+
+
+
+
+
+

장소 검색

+
+

Request

+
+
+
+
+
+
+

로그인 필수: N

+
+
+
+

Query Parameters

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

keyword

검색하고자 하는 키워드

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - latest_uploaded_time(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
HTTP Request 예시
+
+
+
GET /api/v1/places/search?keyword=%ED%82%A4%EC%9B%8C%EB%93%9C&page=1&size=5&sortBy=latest_uploaded_time&sortOrder=desc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

places

Array

place의 정보

places[].id

Number

place의 고유 Id

places[].place_name

String

장소의 장소명

places[].review_count

String

리뷰의 개수

places[].thumbnail_image_url

String

장소의 대표 이미지의 Url

places[].rate

String

장소의 평점 정보

places[].bookmarked

Boolean

해당 장소의 북마크 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 212
+
+{
+  "places" : [ {
+    "id" : 1,
+    "place_name" : "장소명",
+    "review_count" : "10",
+    "thumbnail_image_url" : "http://image.com",
+    "rate" : "3.5",
+    "bookmarked" : true
+  } ],
+  "has_next" : true
+}
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/region-api.html b/backend/yigil-api/src/main/resources/static/docs/region-api.html new file mode 100644 index 000000000..54577b7f8 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/region-api.html @@ -0,0 +1,625 @@ + + + + + + + +REGION API + + + + + +
+
+

REGION API

+
+
+

관심 장소 선택을 위한 정보 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/regions/select HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

categories

Array

지역별 카테고리 정보

categories[].category_name

String

지역별 카테고리 명

categories[].regions

Array

카테고리 내 지역 정보

categories[].regions[].id

Number

지역의 고유 id

categories[].regions[].region_name

String

지역명

categories[].regions[].selected

Boolean

사용자의 해당 지역의 관심지역 설정 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 178
+
+{
+  "categories" : [ {
+    "category_name" : "서울 북부",
+    "regions" : [ {
+      "id" : 1,
+      "region_name" : "홍대 | 와플",
+      "selected" : true
+    } ]
+  } ]
+}
+
+
+
+
+
+
+

사용자의 관심 지역 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/regions/my HTTP/1.1
+Content-Type: application/json
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

regions

Array

지역 정보

regions[].id

Number

지역의 고유 Id

regions[].name

String

지역의 이름

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 72
+
+{
+  "regions" : [ {
+    "id" : 1,
+    "name" : "홍대 | 상수"
+  } ]
+}
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/spot-api.html b/backend/yigil-api/src/main/resources/static/docs/spot-api.html new file mode 100644 index 000000000..cb9a9b4a6 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/spot-api.html @@ -0,0 +1,1392 @@ + + + + + + + +SPOT API + + + + + +
+
+

SPOT API

+
+
+

장소 내 Spot 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : N

+
+
+
Path parameter
+ + ++++ + + + + + + + + + + + + +
Table 1. /api/v1/spots/place/{placeId}
ParameterDescription

placeId

장소 아이디

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - created_at(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/spots/place/1?page=1&size=5&sortBy=created_at&sortOrder=desc HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

has_next

Boolean

다음 페이지가 있는지 여부

spots

Array

spot의 정보

spots[].id

Number

Spot의 고유 아이디

spots[].image_url_list

Array

imageUrl의 List

spots[].description

String

Spot의 설명

spots[].owner_profile_image_url

String

Spot 등록 사용자의 프로필 이미지 Url

spots[].owner_nickname

String

Spot 등록 사용자의 닉네임

spots[].rate

String

Spot의 평점

spots[].create_date

String

Spot의 생성일시

spots[].liked

Boolean

로그인한 사용자의 좋아요 여부

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 328
+
+{
+  "spots" : [ {
+    "id" : 1,
+    "image_url_list" : [ "images/image.png", "images/photo.jpeg" ],
+    "description" : "설명",
+    "owner_profile_image_url" : "images/profile.jpg",
+    "owner_nickname" : "오너 닉네임",
+    "rate" : "4.5",
+    "create_date" : "2024-02-01",
+    "liked" : true
+  } ],
+  "has_next" : true
+}
+
+
+
+
+
+
+

장소 내 내가 작성한 Spot 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 2. /api/v1/spots/place/{placeId}/me
ParameterDescription

placeId

장소 아이디

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/spots/place/1/me HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

exists

Boolean

스팟이 존재하는지 여부

rate

String

스팟의 평점 정보

image_urls

Array

스팟 관련 이미지의 url 배열

create_date

String

스팟의 생성 일자

description

String

스팟의 본문

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 183
+
+{
+  "exists" : true,
+  "rate" : "4.5",
+  "image_urls" : [ "images/image.jpg", "images/thumb.png" ],
+  "create_date" : "2024-02-05",
+  "description" : "내가 쓴 리뷰리뷰리뷰"
+}
+
+
+
+
+
+
+

Spot 신규 등록

+
+

노션 링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

pointJson

String

스팟의 위치를 나타내는 geojson

title

String

스팟의 제목

description

String

스팟의 본문

rate

Number

스팟 관련 평점 정보

placeName

String

스팟 관련 장소 명

placeAddress

String

스팟 관련 장소 주소

placePointJson

String

스팟 관련 장소의 위치를 나타내는 geojson

+
+

로그인 필수 : Y

+
+
+
Request Parts
+ ++++ + + + + + + + + + + + + + + + + + + + + +
PartDescription

files

Spot의 이미지 파일 (다중파일)

mapStaticImageFile

Spot의 장소를 나타내는 지도 이미지 파일(필수x)

placeImageFile

Spot의 장소를 나타내는 썸네일 이미지 파일(필수x)

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/spots HTTP/1.1
+Content-Type: multipart/form-data; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Length: 330
+Host: spring.restdocs.test
+
+{
+  "pointJson" : "{ \"type\" : \"Point\", \"coordinates\": [ 555,  555 ] }",
+  "title" : "스팟 타이틀",
+  "description" : "스팟 본문",
+  "rate" : 5.0,
+  "placeName" : "장소 타이틀",
+  "placeAddress" : "장소구 장소면 장소리",
+  "placePointJson" : "{ \"type\" : \"Point\", \"coordinates\": [ 555,  555 ] }"
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 38
+
+{
+  "message" : "Spot 생성 완료"
+}
+
+
+
+
+
+
+

Spot 상세 정보 조회

+
+

Request

+
+
+
GET /api/v1/spots/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+

로그인 필수 : N

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 3. /api/v1/spots/{spotId}
ParameterDescription

spotId

스팟 아이디

+
+
+
HTTP Request 예시
+
+
+
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

place_name

String

스팟 관련 장소 명

rate

String

스팟의 평점 정보

place_address

String

스팟 관련 장소의 주소

map_static_image_file_url

String

스팟의 위치를 나타내는 이미지 파일의 상대경로

image_urls

Array

스팟 관련 이미지의 상대 경로 배열

create_date

String

스팟의 생성 일자

description

String

스팟의 본문 정보

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 286
+
+{
+  "place_name" : "장소명",
+  "rate" : "3.0",
+  "place_address" : "장소시 장소구 장소동",
+  "map_static_image_file_url" : "images/mapstatic.png",
+  "image_urls" : [ "images/spot.png", "images/spot.jpeg" ],
+  "create_date" : "2024-02-01",
+  "description" : "스팟 설명"
+}
+
+
+
+
+
+
+

Spot 수정

+
+

링크 :

+
+
+

Request

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

id

Number

스팟 아이디

description

String

스팟의 본문 정보

rate

Number

스팟의 평점 정보

originalSpotImages

Array

기존 스팟 이미지 정보

originalSpotImages[].imageUrl

String

기존 스팟 이미지의 url

originalSpotImages[].index

Number

기존 스팟 이미지의 index

updateSpotImages

Array

업데이트 할 스팟 이미지 정보

updateSpotImages[].index

Number

업데이트 할 스팟 이미지의 index

+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 4. /api/v1/spots/{spotId}
ParameterDescription

spotId

스팟 아이디

+
+
+
Request Parts
+ ++++ + + + + + + + + + + + + +
PartDescription

updateSpotImages[0].imageFile

업데이트 할 스팟의 새로운 이미지 파일

+
+
+
HTTP Request 예시
+
+
+
{
+  "id" : 1,
+  "description" : "스팟 설명",
+  "rate" : 4.5,
+  "originalSpotImages" : [ {
+    "imageUrl" : "images/spot.jpg",
+    "index" : 0
+  } ],
+  "updateSpotImages" : [ {
+    "index" : 0
+  } ]
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 38
+
+{
+  "message" : "Spot 수정 완료"
+}
+
+
+
+
+
+
+

Spot 삭제

+
+

Request

+
+
+
DELETE /api/v1/spots/1 HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+

로그인 필수 : Y

+
+
+
Path Parameter
+ + ++++ + + + + + + + + + + + + +
Table 5. /api/v1/spots/{spotId}
ParameterDescription

spotId

스팟 아이디

+
+
+
HTTP Request 예시
+
+
+
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 38
+
+{
+  "message" : "Spot 삭제 완료"
+}
+
+
+
+
+
+
+

내 Spot 목록 조회

+
+

Request

+
+
+
+
+
+
+

로그인 필수 : Y

+
+
+
Query Parameter
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

page

현재 페이지 - default:1

size

페이지 크기 - default:5

sortBy

정렬 옵션 - createdAt(디폴트값) / rate

sortOrder

정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순

selected

필터 기능 - all(디폴트값) 전체공개 / private 비공개

+
+
+
HTTP Request 예시
+
+
+
GET /api/v1/spots/my?page=1&size=5&sortBy=created_at&sortOrder=desc&selected=public HTTP/1.1
+Host: spring.restdocs.test
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

content[].spot_id

Number

장소 ID

content[].title

String

장소 제목

content[].rate

Number

장소 평점

content[].image_url

String

장소 이미지 URL

content[].created_date

String

장소 생성일

content[].is_private

Boolean

공개여부

total_pages

Number

총 페이지 수

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 210
+
+{
+  "content" : [ {
+    "spot_id" : 1,
+    "title" : "test course",
+    "rate" : 4.5,
+    "image_url" : "images/map.jpg",
+    "created_date" : "2024-01-01",
+    "is_private" : false
+  } ],
+  "total_pages" : 1
+}
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/main/resources/static/docs/travel-api.html b/backend/yigil-api/src/main/resources/static/docs/travel-api.html new file mode 100644 index 000000000..db7a30a60 --- /dev/null +++ b/backend/yigil-api/src/main/resources/static/docs/travel-api.html @@ -0,0 +1,665 @@ + + + + + + + +Travel API + + + + + +
+
+

Travel API

+
+
+

게시글 공개 상태로 전환

+
+

Request

+
+
+
{
+  "travel_id" : 1
+}
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/travels/change-on-public HTTP/1.1
+Content-Type: application/json
+Content-Length: 21
+Host: spring.restdocs.test
+
+{
+  "travel_id" : 1
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 54
+
+{
+  "message" : "리뷰 공개 상태 변경 완료"
+}
+
+
+
+
+
+
+

게시글 비공개 상태로 전환

+
+

Request

+
+
+
{
+  "travel_id" : 2
+}
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/travels/change-on-private HTTP/1.1
+Content-Type: application/json
+Content-Length: 21
+Host: spring.restdocs.test
+
+{
+  "travel_id" : 2
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

응답의 본문 메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 54
+
+{
+  "message" : "리뷰 공개 상태 변경 완료"
+}
+
+
+
+
+
+
+

게시글 리스트 공개/비공개 전환

+
+

Request

+
+
+
{
+  "travel_ids" : [ 1 ],
+  "is_private" : false
+}
+
+
+
+

로그인 필수 : Y

+
+
+
HTTP Request 예시
+
+
+
POST /api/v1/travels/change-visibility HTTP/1.1
+Content-Type: application/json
+Content-Length: 50
+Host: spring.restdocs.test
+
+{
+  "travel_ids" : [ 1 ],
+  "is_private" : false
+}
+
+
+
+
+
+

Response

+ +++++ + + + + + + + + + + + + + + +
PathTypeDescription

message

String

메시지

+
+
HTTP Response 예시
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 54
+
+{
+  "message" : "리뷰 공개 상태 변경 완료"
+}
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/RestDocumentUtils.java b/backend/yigil-api/src/test/java/kr/co/yigil/RestDocumentUtils.java new file mode 100644 index 000000000..365a3defc --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/RestDocumentUtils.java @@ -0,0 +1,23 @@ +package kr.co.yigil; + +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris; +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 org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; + +public interface RestDocumentUtils { + + static OperationRequestPreprocessor getDocumentRequest() { + return preprocessRequest(modifyUris().scheme("http") + .host("spring.restdocs.test") + .removePort(), prettyPrint()); + } + + static OperationResponsePreprocessor getDocumentResponse() { + return preprocessResponse(prettyPrint()); + } + +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/application/BookmarkFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/application/BookmarkFacadeTest.java new file mode 100644 index 000000000..a2c4560f4 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/application/BookmarkFacadeTest.java @@ -0,0 +1,48 @@ +package kr.co.yigil.bookmark.application; + +import kr.co.yigil.bookmark.domain.BookmarkService; +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 static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class BookmarkFacadeTest { + + @Mock + private BookmarkService bookmarkService; + + @InjectMocks + private BookmarkFacade bookmarkFacade; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("addBookmark 메서드가 BookmarkService의 메서드를 잘 호출하는지") + @Test + void whenAddBookmark_thenCallsMethods() { + Long memberId = 1L; + Long placeId = 2L; + + bookmarkFacade.addBookmark(memberId, placeId); + + verify(bookmarkService, times(1)).addBookmark(memberId, placeId); + } + + @DisplayName("deleteBookmark 메서드가 BookmarkService의 메서드를 잘 호출하는지") + @Test + void whenDeleteBookmark_thenCallsMethods() { + Long memberId = 1L; + Long placeId = 2L; + + bookmarkFacade.deleteBookmark(memberId, placeId); + + verify(bookmarkService, times(1)).deleteBookmark(memberId, placeId); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/domain/BookmarkServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/domain/BookmarkServiceImplTest.java new file mode 100644 index 000000000..4ae24136e --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/domain/BookmarkServiceImplTest.java @@ -0,0 +1,106 @@ +package kr.co.yigil.bookmark.domain; + +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceReader; +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 static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BookmarkServiceImplTest { + + @Mock + private BookmarkReader bookmarkReader; + + @Mock + private MemberReader memberReader; + + @Mock + private PlaceReader placeReader; + + @Mock + private BookmarkStore bookmarkStore; + + @InjectMocks + private BookmarkServiceImpl bookmarkServiceImpl; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("유효한 파라미터로 addBookmark 메서드가 잘 호출되는지") + @Test + void whenAddBookmark_valid_thenCallsMethod() { + Long memberId = 1L; + Long placeId = 2L; + + when(bookmarkReader.isBookmarked(anyLong(), anyLong())).thenReturn(false); + + Member mockMember = mock(Member.class); + when(memberReader.getMember(anyLong())).thenReturn(mockMember); + + Place mockPlace = mock(Place.class); + when(placeReader.getPlace(anyLong())).thenReturn(mockPlace); + + bookmarkServiceImpl.addBookmark(memberId, placeId); + + verify(bookmarkStore, times(1)).store(any(Member.class), any(Place.class)); + } + + @DisplayName("이미 북마크된 장소에 대해 addBookmark를 호출 시 예외가 잘 발생되는지") + @Test + void whenAddBookmark_alreadyBookmarked_thenThrowsException() { + Long memberId = 1L; + Long placeId = 2L; + + when(bookmarkReader.isBookmarked(anyLong(), anyLong())).thenReturn(true); + + assertThrows( + BadRequestException.class, () -> bookmarkServiceImpl.addBookmark(memberId, placeId)); + } + + @DisplayName("유효한 파라미터로 deleteBookmark 메서드가 잘 호출되는지") + @Test + void whenDeleteBookmark_valid_thenCallsMethod() { + Long memberId = 1L; + Long placeId = 2L; + + when(bookmarkReader.isBookmarked(anyLong(), anyLong())).thenReturn(true); + + Member mockMember = mock(Member.class); + when(memberReader.getMember(anyLong())).thenReturn(mockMember); + + Place mockPlace = mock(Place.class); + when(placeReader.getPlace(anyLong())).thenReturn(mockPlace); + + bookmarkServiceImpl.deleteBookmark(memberId, placeId); + + verify(bookmarkStore, times(1)).remove(any(Member.class), any(Place.class)); + } + + @DisplayName("북마크되지 않은 장소에 대해 deleteBookmark를 호출 시 예외가 잘 발생되는지") + @Test + void whenDeleteBookmark_notBookmarked_thenThrowsException() { + Long memberId = 1L; + Long placeId = 2L; + + when(bookmarkReader.isBookmarked(anyLong(), anyLong())).thenReturn(false); + + assertThrows( + BadRequestException.class, () -> bookmarkServiceImpl.deleteBookmark(memberId, placeId)); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/infrastructure/BookmarkReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/infrastructure/BookmarkReaderImplTest.java new file mode 100644 index 000000000..babf043a0 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/infrastructure/BookmarkReaderImplTest.java @@ -0,0 +1,66 @@ +package kr.co.yigil.bookmark.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +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.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +public class BookmarkReaderImplTest { + + @Mock + private BookmarkRepository bookmarkRepository; + + @Mock + private MemberReader memberReader; + + @InjectMocks + private BookmarkReaderImpl bookmarkReader; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("getBookmarkSlice 메서드가 올바른 Slice를 반환하는지") + @Test + void whenGetBookmarkSlice_thenReturnsCorrectSlice() { + Long memberId = 1L; + Member member = new Member("kiit0901@gmail.com", "123456", "stone", "profile.jpg", "kakao"); + List bookmarks = new ArrayList<>(); + Slice expectedSlice = new SliceImpl<>(bookmarks); + + when(memberReader.getMember(memberId)).thenReturn(member); + when(bookmarkRepository.findAllByMember(member, Pageable.unpaged())).thenReturn(expectedSlice); + + Slice actualSlice = bookmarkReader.getBookmarkSlice(memberId, Pageable.unpaged()); + + assertEquals(expectedSlice, actualSlice); + } + + @DisplayName("isBookmarked 메서드가 올바른 결과를 반환하는지") + @Test + void whenIsBookmarked_thenReturnsCorrectResult() { + Long memberId = 1L; + Long placeId = 2L; + + when(bookmarkRepository.existsByMemberIdAndPlaceId(memberId, placeId)).thenReturn(true); + + boolean isBookmarked = bookmarkReader.isBookmarked(memberId, placeId); + + assertTrue(isBookmarked); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/infrastructure/BookmarkStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/infrastructure/BookmarkStoreImplTest.java new file mode 100644 index 000000000..c64413c1f --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/infrastructure/BookmarkStoreImplTest.java @@ -0,0 +1,64 @@ +package kr.co.yigil.bookmark.infrastructure; + +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.FileType; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class BookmarkStoreImplTest { + + @Mock + private BookmarkRepository bookmarkRepository; + + @InjectMocks + private BookmarkStoreImpl bookmarkStore; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("북마크가 요청되었을 때 BookmarkRepository의 save 메서드가 호출되는지") + @Test + void whenBookmarkStored_thenSaveIsCalled() { + Member member = new Member("kiit0901@gmail.com", "123456", "stone", "profile.jpg", "kakao"); + + GeometryFactory geometryFactory = new GeometryFactory(); + Point mockPoint = geometryFactory.createPoint(new Coordinate(0, 0)); + AttachFile mockAttachFile = new AttachFile(FileType.IMAGE, "img.url", "original.name", 10L ); + Place mockPlace = new Place("패스트캠퍼스", "봉은사역 근처", 0.0, mockPoint, mockAttachFile, mockAttachFile, null); + + bookmarkStore.store(member, mockPlace); + + verify(bookmarkRepository, times(1)).save(new Bookmark(member, mockPlace)); + } + + @DisplayName("북마크 삭제가 요청되었을 때 BookmarkRepository의 deleteByMemberAndPlaceId 메서드가 호출되는지") + @Test + void whenBookmarkRemoved_thenDeleteByMemberAndPlaceIdIsCalled() { + Member member = new Member("kiit0901@gmail.com", "123456", "stone", "profile.jpg", "kakao"); + Long placeId = 1L; + GeometryFactory geometryFactory = new GeometryFactory(); + Point mockPoint = geometryFactory.createPoint(new Coordinate(0, 0)); + AttachFile mockAttachFile = new AttachFile(FileType.IMAGE, "img.url", "original.name", 10L); + Place mockPlace = new Place("패스트캠퍼스", "봉은사역 근처",0.0, mockPoint, mockAttachFile, mockAttachFile, null); + + + bookmarkStore.remove(member, mockPlace); + + verify(bookmarkRepository, times(1)).deleteByMemberAndPlace(member, mockPlace); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/interfaces/controller/BookmarkApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/interfaces/controller/BookmarkApiControllerTest.java new file mode 100644 index 000000000..99f95db62 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/bookmark/interfaces/controller/BookmarkApiControllerTest.java @@ -0,0 +1,149 @@ +package kr.co.yigil.bookmark.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +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.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import kr.co.yigil.bookmark.application.BookmarkFacade; +import kr.co.yigil.bookmark.domain.Bookmark; +import kr.co.yigil.bookmark.interfaces.dto.BookmarkInfoDto; +import kr.co.yigil.bookmark.interfaces.dto.mapper.BookmarkMapper; +import kr.co.yigil.bookmark.interfaces.dto.response.BookmarksResponse; +import kr.co.yigil.member.Member; +import kr.co.yigil.place.domain.Place; +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.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.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(BookmarkApiController.class) +@AutoConfigureRestDocs +class BookmarkApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private BookmarkFacade bookmarkFacade; + + @MockBean + private BookmarkMapper bookmarkMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation)).build(); + } + + @DisplayName("북마크 추가 요청시 200 응답과 response가 잘 반환되는지") + @Test + void whenAddBookmark_thenReturns200AndAddBookmarkResponse() throws Exception { + Long placeId = 1L; + + mockMvc.perform(post("/api/v1/add-bookmark/{place_id}", placeId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().json("{\"message\":\"장소 북마크 추가 성공\"}")) + .andDo(document("bookmarks/add-bookmark", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("place_id").description("북마크할 장소 아이디") + ), + responseFields( + fieldWithPath("message").description("응답 메시지") + ) + )); + + } + + @DisplayName("북마크 삭제 요청시 200 응답과 response가 잘 반환되는지") + @Test + void whenDeleteBookmark_thenReturns200AndDeleteBookmarkResponse() throws Exception { + Long placeId = 1L; + + mockMvc.perform(post("/api/v1/delete-bookmark/{place_id}", placeId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().json("{\"message\":\"장소 북마크 제거 성공\"}")) + .andDo(document("bookmarks/delete-bookmark", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("place_id").description("북마크 취소할 장소 아이디") + ), + responseFields( + fieldWithPath("message").description("응답 메시지") + ) + )); + } + + @DisplayName("북마크 조회 요청시 200 응답과 response가 잘 반환되는지") + @Test + void whenGetBookmarks_thenReturns200AndBookmarksResponse() throws Exception { + Bookmark bookmark = new Bookmark(mock(Member.class), mock(Place.class)); + PageRequest pageRequest = PageRequest.of(0, 5); + Slice bookmarkSlice = new SliceImpl<>(List.of(bookmark), pageRequest, true); + when(bookmarkFacade.getBookmarkSlice(anyLong(), any(PageRequest.class))).thenReturn(bookmarkSlice); + + BookmarkInfoDto bookmarkInfoDto = new BookmarkInfoDto(1L, "placeName", "placeImage", 5.0); + BookmarksResponse bookmarksResponse = new BookmarksResponse(List.of(bookmarkInfoDto), true); + when(bookmarkMapper.bookmarkSliceToBookmarksResponse(bookmarkSlice)).thenReturn(bookmarksResponse); + mockMvc.perform(get("/api/v1/bookmarks") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("bookmarks/get-bookmarks", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("현재 페이지 - default:1").optional(), + parameterWithName("size").description("페이지 크기 - default:5").optional(), + parameterWithName("sortBy").description("정렬 옵션 - created_at(디폴트값) / rate").optional(), + parameterWithName("sortOrder").description("정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순").optional() + ), + responseFields( + fieldWithPath("has_next").type(JsonFieldType.BOOLEAN).description("다음 페이지가 있는지 여부"), + subsectionWithPath("bookmarks").description("Bookmark의 정보"), + fieldWithPath("bookmarks[].place_id").description("Bookmark한 장소 아이디"), + fieldWithPath("bookmarks[].place_name").description("Bookmark한 장소 이름"), + fieldWithPath("bookmarks[].place_image").description("Bookmark한 장소 이미지 URL"), + fieldWithPath("bookmarks[].rate").description("Bookmark한 장소 평점") + ) + )); + verify(bookmarkFacade).getBookmarkSlice(anyLong(), any(PageRequest.class)); + verify(bookmarkMapper).bookmarkSliceToBookmarksResponse(bookmarkSlice); + + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/comment/application/CommentFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/comment/application/CommentFacadeTest.java new file mode 100644 index 000000000..2703d620c --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/comment/application/CommentFacadeTest.java @@ -0,0 +1,200 @@ +package kr.co.yigil.comment.application; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.comment.domain.Comment; +import kr.co.yigil.comment.domain.CommentCommand; +import kr.co.yigil.comment.domain.CommentInfo.CommentNotiInfo; +import kr.co.yigil.comment.domain.CommentService; +import kr.co.yigil.member.Member; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +import kr.co.yigil.travel.domain.Travel; +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; + +@ExtendWith(MockitoExtension.class) +class CommentFacadeTest { + + @InjectMocks + private CommentFacade commentFacade; + + @Mock + private CommentService commentService; + + @Mock + private NotificationService notificationService; + + @DisplayName("createComment 메서드가 유효한 요청이 들어왔을 때 CommentService의 createComment 메서드와 알림을 잘 호출하는지") + @Test + void whenCreateComment_thenShouldNotThrowAnError() { + // given + Long commentMemberId = 3L; + Long travelId = 1L; + Long travelMemberId = 1L; + Long parentId = null; + + var commentCreateRequest = CommentCommand.CommentCreateRequest.builder() + .content("content") + .parentId(parentId) + .build(); + Member traveMember = new Member(travelMemberId, null, null, null, null, null); + Member commentMember = new Member(commentMemberId, null, null, null, null, null); + Travel travel = new Travel(travelId, traveMember, null, null, 0, false); + Comment createdComment = new Comment("child content", commentMember, travel, null); + + CommentNotiInfo commentNotiInfo = new CommentNotiInfo(createdComment); + when(commentService.createComment(commentMemberId, travelId, commentCreateRequest)) + .thenReturn(commentNotiInfo); + + commentFacade.createComment(commentMemberId, travelId, commentCreateRequest); + + verify(notificationService).sendNotification(NotificationType.NEW_COMMENT, commentMemberId, travelMemberId); + + } + + @DisplayName("createComment 메서드가 유효한 요청이 들어왔을 때 CommentService의 createComment 메서드와 알림을 잘 호출하는지") + @Test + void whenCreateComment_thenShouldNotThrowAnError2() { + // given + Long commentMemberId = 3L; + Long travelId = 1L; + Long travelMemberId = 1L; + Long parentCommentMemberId = 2L; + Long parentId = 2L; + + var commentCreateRequest = CommentCommand.CommentCreateRequest.builder() + .content("content") + .parentId(parentId) + .build(); + Member traveMember = new Member(travelMemberId, null, null, null, null, null); + Member parentMember = new Member(parentCommentMemberId, null, null, null, null, null); + Member commentMember = new Member(commentMemberId, null, null, null, null, null); + + Travel travel = new Travel(travelId, traveMember, null, null, 0, false); + Comment parentComment = new Comment("parent content", parentMember, travel, null); + Comment createdComment = new Comment("child content", commentMember, travel, parentComment); + + CommentNotiInfo commentNotiInfo = new CommentNotiInfo(createdComment); + when(commentService.createComment(commentMemberId, travelId, commentCreateRequest)) + .thenReturn(commentNotiInfo); + + commentFacade.createComment(commentMemberId, travelId, commentCreateRequest); + + verify(notificationService).sendNotification(NotificationType.NEW_COMMENT, commentMemberId, parentCommentMemberId); + } + + @DisplayName("getParentCommentList 메서드가 유효한 요청이 들어왔을 때 응답을 잘 주는지") + @Test + void whenGetParentCommentList_thenShouldReturnCommentResponse() { + // given + Long travelId = 1L; + var pageable = PageRequest.of(0, 5); + + // when + commentFacade.getParentCommentList(travelId, pageable); + + // then + verify(commentService).getParentComments(travelId, pageable); + + + } + + @DisplayName("getChildCommentList 메서드가 유효한 요청이 들어왔을 때 응답을 잘 주는지") + @Test + void WhenGetChildCommentList_thenShouldReturnCommentResponse() { + // given + Long parentId = 1L; + var pageable = PageRequest.of(0, 5); + + // when + commentFacade.getChildCommentList(parentId, pageable); + + // then + verify(commentService).getChildComments(parentId, pageable); + } + + @DisplayName("deleteComment 메서드가 유효한 요청이 들어왔을 때 CommentService의 deleteComment 메서드를 잘 호출하는지") + @Test + void whenDeleteComment_thenShouldCallCommentService() { + // given + Long memberId = 1L; + Long commentId = 1L; + + // when + commentFacade.deleteComment(memberId, commentId); + + // then + verify(commentService).deleteComment(memberId, commentId); + + } + + @DisplayName("updateComment 메서드가 유효한 요청이 들어왔을 때 CommentService의 updateComment 메서드를 잘 호출하는지") + @Test + void updateComment() { + // given + Long commentMemberId = 3L; + Long commentId = 1L; + Long travelId = 1L; + Long travelMemberId = 1L; + + var command = CommentCommand.CommentUpdateRequest.builder() + .content("content") + .build(); + Member traveMember = new Member(travelMemberId, null, null, null, null, null); + Member commentMember = new Member(commentMemberId, null, null, null, null, null); + + Travel travel = new Travel(travelId, traveMember, null, null, 0, false); + + Comment createdComment = new Comment("child content", commentMember, travel, null); + + CommentNotiInfo commentNotiInfo = new CommentNotiInfo(createdComment); + + when(commentService.updateComment(commentId, commentMemberId, command)) + .thenReturn(commentNotiInfo); + + // when + commentFacade.updateComment(commentMemberId, commentId, command); + + // then + + verify(notificationService).sendNotification(NotificationType.UPDATE_COMMENT, commentMemberId, travelMemberId); + + } + @DisplayName("updateComment 메서드가 유효한 요청이 들어왔을 때 CommentService의 updateComment 메서드를 잘 호출하는지") + @Test + void whenUpdateComment_givenChildrenCommentId_thenShouldNotifyToParentComment() { + // given + Long commentMemberId = 3L; + Long commentId = 1L; + Long travelId = 1L; + Long travelMemberId = 1L; + Long parentCommentMemberId = 2L; + + var command = CommentCommand.CommentUpdateRequest.builder() + .content("content") + .build(); + Member traveMember = new Member(travelMemberId, null, null, null, null, null); + Member parentMember = new Member(parentCommentMemberId, null, null, null, null, null); + Member commentMember = new Member(commentMemberId, null, null, null, null, null); + + Travel travel = new Travel(travelId, traveMember, null, null, 0, false); + Comment parentComment = new Comment("parent content", parentMember, travel, null); + Comment createdComment = new Comment("child content", commentMember, travel, parentComment); + + CommentNotiInfo commentNotiInfo = new CommentNotiInfo(createdComment); + + when(commentService.updateComment(commentId, commentMemberId, command)) + .thenReturn(commentNotiInfo); + + commentFacade.updateComment(commentMemberId, commentId, command); + + verify(notificationService).sendNotification(NotificationType.UPDATE_COMMENT, commentMemberId, parentCommentMemberId); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/comment/application/CommentRedisIntegrityServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/comment/application/CommentRedisIntegrityServiceTest.java deleted file mode 100644 index 591d44e5e..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/comment/application/CommentRedisIntegrityServiceTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package kr.co.yigil.comment.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import kr.co.yigil.comment.domain.CommentCount; -import kr.co.yigil.comment.domain.repository.CommentCountRepository; -import kr.co.yigil.comment.domain.repository.CommentRepository; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.travel.domain.Spot; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.Point; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -public class CommentRedisIntegrityServiceTest { - - @Mock - private CommentRepository commentRepository; - - @Mock - private CommentCountRepository commentCountRepository; - - @InjectMocks - private CommentRedisIntegrityService commentRedisIntegrityService; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - } - - @DisplayName("ensureCommentCount 메서드가 이미 존재하는 CommentCount를 반환하는지") - @Test - void testEnsureCommentCountWhenAlreadyExists() { - - Long memberId = 1L; - Member mockMember = new Member(memberId, "shin@gmail.com", "123456", "똷", "profile.jpg", SocialLoginType.KAKAO); - - GeometryFactory geometryFactory = new GeometryFactory(); - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(mockPoint1,"spot title1", "spot file url1","spot description1"); - Long postId = 1L; - Post post = new Post(postId, mockSpot1, mockMember); - CommentCount existingCommentCount = new CommentCount(1L, 5); - - when(commentCountRepository.findByPostId(postId)).thenReturn(Optional.of(existingCommentCount)); - - CommentCount result = commentRedisIntegrityService.ensureCommentCount(post); - - assertThat(result).isEqualTo(existingCommentCount); - verify(commentRepository, never()).countNonDeletedCommentsByPostId(post.getId()); - verify(commentCountRepository, never()).save(any()); - } - - @DisplayName("ensureCommentCount 메서드가 존재하지 않을 경우 CommentCount를 생성하고 저장하는지") - @Test - void testEnsureCommentCountWhenNotExists() { - Long memberId = 1L; - Member mockMember = new Member(memberId, "shin@gmail.com", "123456", "God", "profile.jpg", SocialLoginType.KAKAO); - - GeometryFactory geometryFactory = new GeometryFactory(); - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(mockPoint1,"spot title1", "spot file url1","spot description1"); - Long postId = 1L; - Post post = new Post(postId, mockSpot1, mockMember); - - CommentCount newCommentCount = new CommentCount(1L, 10); - - when(commentCountRepository.findByPostId(post.getId())).thenReturn(Optional.empty()); - when(commentRepository.countNonDeletedCommentsByPostId(post.getId())).thenReturn(10); - when(commentCountRepository.save(any())).thenReturn(newCommentCount); - - CommentCount count = commentRedisIntegrityService.ensureCommentCount(post); - - assertThat(count.getCommentCount()).isEqualTo(newCommentCount.getCommentCount()); - verify(commentRepository).countNonDeletedCommentsByPostId(post.getId()); - verify(commentCountRepository).save(any()); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/comment/application/CommentServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/comment/application/CommentServiceTest.java deleted file mode 100644 index 2a0d08bbd..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/comment/application/CommentServiceTest.java +++ /dev/null @@ -1,299 +0,0 @@ -package kr.co.yigil.comment.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -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 kr.co.yigil.comment.domain.Comment; -import kr.co.yigil.comment.domain.CommentCount; -import kr.co.yigil.comment.domain.repository.CommentCountRepository; -import kr.co.yigil.comment.domain.repository.CommentRepository; -import kr.co.yigil.comment.dto.request.CommentCreateRequest; -import kr.co.yigil.comment.dto.response.CommentCreateResponse; -import kr.co.yigil.comment.dto.response.CommentDeleteResponse; -import kr.co.yigil.comment.dto.response.CommentResponse; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; -import kr.co.yigil.member.application.MemberService; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.notification.application.NotificationService; -import kr.co.yigil.notification.domain.Notification; -import kr.co.yigil.post.application.PostService; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.travel.domain.Spot; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.Point; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - - -@ExtendWith(MockitoExtension.class) -class CommentServiceTest { - - @InjectMocks - private CommentService commentService; - @Mock - private CommentRepository commentRepository; - @Mock - private MemberService memberService; - @Mock - private PostService postService; - @Mock - private NotificationService notificationService; - @Mock - private CommentRedisIntegrityService commentRedisIntegrityService; - @Mock - private CommentCountRepository commentCountRepository; - - - @DisplayName("createComment 메서드가 유효한 인자(부모 댓글이 없는 경우)를 넘겨받았을 때 올바른 응답을 내리는지.") - @Test - void whenCreateComment_thenReturnCommentCreateResponse() { - - Long memberId = 1L; - Member mockMember = new Member("shin@gmail.com", "123456", "똷", "profile.jpg", SocialLoginType.KAKAO); - - GeometryFactory geometryFactory = new GeometryFactory(); - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(mockPoint1,"spot title1", "spot file url1","spot description1"); - Long postId = 1L; - Post mockPost = new Post(postId, mockSpot1, mockMember); - - String content = "댓글 내용"; - CommentCreateRequest commentCreateRequest = new CommentCreateRequest(content, null, 2L); - when(memberService.findMemberById(memberId)).thenReturn(mockMember); - when(postService.findPostById(anyLong())).thenReturn(mockPost); - - int commentCount = 3; - when(commentRedisIntegrityService.ensureCommentCount(mockPost)).thenReturn(new CommentCount(postId, commentCount)); - when(commentCountRepository.findByPostId(postId)).thenReturn(Optional.of(new CommentCount(postId, commentCount))); - commentService.createComment(memberId, postId, commentCreateRequest); - - verify(commentRepository,times(1)).save(any(Comment.class)); - verify(commentRedisIntegrityService,times(1)).ensureCommentCount(mockPost); - - assertThat(commentCountRepository.findByPostId(postId).get().getCommentCount()).isEqualTo( commentCount + 1); - assertThat(commentService.createComment(memberId, postId, commentCreateRequest)).isInstanceOf( - CommentCreateResponse.class); - } - - @DisplayName("createComment CommentRequest에 parentId가 있을 때 올바른 응답을 내리는지.") - @Test - void givenParentId_whenCreateComment_returnValidResponse() { - Long memberId = 1L; - Long notifiedMemberId = 2L; - Long commentId = 3L; - Member mockMember = new Member("shin@gmail.com", "123456", "God", "profile.jpg", SocialLoginType.KAKAO); - - GeometryFactory geometryFactory = new GeometryFactory(); - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(mockPoint1,"spot title1", "spot file url1","spot description1"); - Long postId = 1L; - Post mockPost = new Post(postId, mockSpot1, mockMember); - - String content = "댓글 내용"; - Long mockParentId = 1L; - - CommentCreateRequest commentCreateRequest = new CommentCreateRequest(content, mockParentId, notifiedMemberId); - - Member mockNotifiedMember = new Member("hoyoon@gmail.com", "1234567", "Alex", "hoyun.jpg", SocialLoginType.KAKAO); - Comment mockComment = new Comment(commentId, content,mockMember, mockPost); - - Comment mockParentComment = new Comment(1L, "부모컨텐츠", mockNotifiedMember, mockPost); - when(memberService.findMemberById(notifiedMemberId)).thenReturn(mockNotifiedMember); - when(commentRepository.findById(mockParentId)).thenReturn(Optional.of(mockParentComment)); - - when(memberService.findMemberById(memberId)).thenReturn(mockMember); - when(postService.findPostById(anyLong())).thenReturn(mockPost); - when(commentRepository.save(any(Comment.class))).thenReturn(mockComment); - - int commentCount = 3; - when(commentCountRepository.findByPostId(postId)).thenReturn(Optional.of(new CommentCount(postId, commentCount))); - - commentService.createComment(memberId, postId, commentCreateRequest); - verify(notificationService, times(1)).sendNotification(any(Notification.class)); - verify(commentRedisIntegrityService,times(1)).ensureCommentCount(mockPost); - - assertThat(commentCountRepository.findByPostId(postId).get().getCommentCount()).isEqualTo(commentCount + 1); - assertThat(commentService.createComment(memberId, postId, commentCreateRequest)).isInstanceOf( - CommentCreateResponse.class); - } - - @DisplayName("getcommentList 메서드 실행 시 comment list 가 잘 반환되는지") - @Test - void whenGetCommentList_thenReturnCommentResponse() { - Member mockMember = new Member("shin@gmail.com", "123456", "God", "profile.jpg", SocialLoginType.KAKAO); - GeometryFactory geometryFactory = new GeometryFactory(); - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(mockPoint1,"spot title1", "spot file url1","spot description1"); - Long postId = 1L; - Post mockPost = new Post(postId, mockSpot1, mockMember); - String content = "댓글 내용"; - - Comment mockComment = new Comment(1L, content, mockMember, mockPost); - Comment mockChildComment1 = new Comment(2L, "자식1", mockMember, mockPost, mockComment); - Comment mockChildComment2 = new Comment(3L, "자식1", mockMember, mockPost, mockComment); - - when(commentRepository.findTopLevelCommentsByPostId(anyLong())).thenReturn(List.of(mockComment)); - when(commentRepository.findRepliesByPostIdAndParentId(anyLong(), anyLong())).thenReturn(List.of(mockChildComment1, mockChildComment2)); - - assertThat(commentService.getCommentList(postId)).isInstanceOf(List.class); - assertThat(commentService.getCommentList(postId).get(0)).isInstanceOf(CommentResponse.class); - assert(commentService.getCommentList(postId).size() == 1); - } - - @DisplayName("deleteComment 메서드가 유효한 인자를 받았을 때 comment 가 잘 삭제되는지") - @Test - void givenValidParameter_whenDeleteComment_thenReturnDeleteCommentResponse() { - Member mockMember = new Member("shin@gmail.com", "123456", "God", "profile.jpg", SocialLoginType.KAKAO); - GeometryFactory geometryFactory = new GeometryFactory(); - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(mockPoint1,"spot title1", "spot file url1","spot description1"); - Long postId = 1L; - Post mockPost = new Post(postId, mockSpot1, mockMember); - when(postService.findPostById(anyLong())).thenReturn(mockPost); - - Long commentId = 1L; - Long memberId = 1L; - String content = "댓글 내용"; - - Comment mockComment = new Comment(commentId, content, mockMember, mockPost); - Comment mockChildComment1 = new Comment(2L, "자식1", mockMember, mockPost, mockComment); - - - int commentCount = 3; - CommentCount mockCommentCount = new CommentCount(postId, commentCount); - when(commentRedisIntegrityService.ensureCommentCount(mockPost)).thenReturn(mockCommentCount); - when(commentRepository.existsByMemberIdAndId(memberId, commentId)).thenReturn(true); - when(commentRepository.findById(anyLong())).thenReturn(java.util.Optional.of(mockComment)); - when(commentCountRepository.findByPostId(anyLong())).thenReturn(Optional.of(mockCommentCount)); - - CommentDeleteResponse commentDeleteResponse = commentService.deleteComment(memberId, postId, commentId); - - verify(commentRedisIntegrityService,times(1)).ensureCommentCount(mockPost); - verify(commentRepository, times(1)).delete(any()); - - assertThat(commentCountRepository.findByPostId(postId).get().getCommentCount()).isEqualTo(commentCount-1); - assertEquals("댓글 삭제 성공", commentDeleteResponse.getMessage()); - } - - @DisplayName("findCommentById 메서드가 유효한 인자를 받았을 때 comment 가 잘 반환되는지") - @Test - void givenValidParameter_whenFindCommentById_thenReturnComment() { - Long commentId = 1L; - - Member mockMember = new Member("shin@gmail.com", "123456", "God", "profile.jpg", SocialLoginType.KAKAO); - - GeometryFactory geometryFactory = new GeometryFactory(); - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(mockPoint1,"spot title1", "spot file url1","spot description1"); - Long postId = 1L; - Post mockPost = new Post(postId, mockSpot1, mockMember); - - Comment mockComment= new Comment(commentId, "content", mockMember, mockPost); - - when(commentRepository.findById(anyLong())).thenReturn(Optional.of(mockComment)); - - // when - Comment result = commentService.findCommentById(commentId); - - //then - assertEquals(mockComment, result); - - } - - @DisplayName("validateCommentWriter 메서드 실행 시 comment 작성자와 로그인한 사용자가 같은지 확인") - @Test - void givenValidParameter_whenvalidateCommentWriter_thenReturnNothing() { - Long memberId = 1L; - Long commentId = 1L; - when(commentRepository.existsByMemberIdAndId(memberId, commentId)).thenReturn(true); - commentService.validateCommentWriter(memberId, commentId); - } - - @DisplayName("validateCommentWriter 메서드 실행 시 comment 작성자와 로그인한 사용자가 다를 때 예외가 발생하는지") - @Test - public void testValidateCommentWriterWhenCommentDoesNotExist() { - // 테스트에 사용될 memberId와 commentId 값 설정 - Long memberId = 1L; - Long commentId = 2L; - - // commentRepository.existsByMemberIdAndId() 메서드의 반환값을 설정 - when(commentRepository.existsByMemberIdAndId(memberId, commentId)).thenReturn(false); - - // 테스트 대상 메서드 호출 - assertThrows(BadRequestException.class, () -> commentService.validateCommentWriter(memberId, commentId)); - - } - - @DisplayName("deleteComment 메서드가 유효하지 않은 인자를 받았을 때 예외가 발생하는지") - @Test - void givenInvalidParameter_whenDeleteComment_thenThrowException() { - Long memberId = 1L; - Long postId = 1L; - Long commentId = 1L; - when(postService.findPostById(anyLong())).thenThrow(new BadRequestException(ExceptionCode.NOT_FOUND_POST_ID)); - assertThrows(BadRequestException.class, () -> commentService.deleteComment(memberId, postId, commentId)); - } - - @DisplayName("getTopLevelCommentList 메서드가 유효한 인자를 받았을 때 comment list 가 잘 반환되는지") - @Test - void givenValidParameter_whenGetTopLevelCommentList_thenReturnCommentResponse() { - Long postId = 1L; - Long memberId = 1L; - - Member mockMember = new Member("shin@gmail.com", "123456", "떫", "profile.jpg", SocialLoginType.KAKAO); - - Post mockPost = new Post(1L, null, mockMember); - Comment mockComment1 = new Comment(1L, "content", mockMember, mockPost); - Comment mockComment2 = new Comment(2L, "content", mockMember, mockPost); - Comment mockComment3 = new Comment(3L, "content", mockMember, mockPost); - - when(commentRepository.findTopLevelCommentsByPostId(anyLong())).thenReturn( - List.of(mockComment1, mockComment2, mockComment3)); - - assertThat(commentService.getTopLevelCommentList(postId)).isInstanceOf(List.class); - assertThat(commentService.getTopLevelCommentList(postId).get(0)).isInstanceOf( - CommentResponse.class); - assert (commentService.getTopLevelCommentList(postId).size() == 3); - } - - @DisplayName("getReplyCommentList 메서드가 유효한 인자를 받았을 때 comment list 가 잘 반환되는지") - @Test - void givenValidParameter_whenGetReplyCommentList_thenReturnCommentResponse() { - Long postId = 1L; - Long parentId = 1L; - Long memberId = 1L; - - Member mockMember = new Member(memberId, "shin@gmail.com", "123456", "떫", "profile.jpg", - SocialLoginType.KAKAO); - - Post mockPost = new Post(1L, null, mockMember); - Comment mockParentComment = new Comment(1L, "content", mockMember, mockPost); - Comment mockComment1 = new Comment(2L, "content", mockMember, mockPost, mockParentComment); - Comment mockComment2 = new Comment(3L, "content", mockMember, mockPost, mockParentComment); - Comment mockComment3 = new Comment(4L, "content", mockMember, mockPost, mockParentComment); - - when(commentRepository.findRepliesByPostIdAndParentId(anyLong(), anyLong())).thenReturn( - List.of(mockComment1, mockComment2, mockComment3)); - - assertThat(commentService.getReplyCommentList(postId, parentId)).isInstanceOf(List.class); - assertThat(commentService.getReplyCommentList(postId, parentId).get(0)).isInstanceOf( - CommentResponse.class); - assert (commentService.getReplyCommentList(postId, parentId).size() == 3); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/comment/domain/CommentServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/comment/domain/CommentServiceImplTest.java new file mode 100644 index 000000000..a35a7dea4 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/comment/domain/CommentServiceImplTest.java @@ -0,0 +1,185 @@ +package kr.co.yigil.comment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import kr.co.yigil.comment.domain.CommentInfo.CommentNotiInfo; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.travel.domain.Travel; +import kr.co.yigil.travel.domain.TravelReader; +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.Slice; +import org.springframework.data.domain.SliceImpl; + +@ExtendWith(MockitoExtension.class) +class CommentServiceImplTest { + + @InjectMocks + private CommentServiceImpl commentService; + + @Mock + private CommentReader commentReader; + @Mock + private CommentStore commentStore; + @Mock + private MemberReader memberReader; + @Mock + private TravelReader travelReader; + @Mock + private CommentCountCacheStore commentCountCacheStore; + + @DisplayName("createComment 메서드가 실행됐을 때 CommentNotiInfo를 잘 반환하는지") + @Test + void whenCreateComment_thenShouldReturnCommentNotiInfo() { + Long memberId = 1L; + Long travelId = 1L; + CommentCommand.CommentCreateRequest commentCreateRequest = new CommentCommand.CommentCreateRequest( + "content", null); + + Member member = new Member(memberId, null, null, null, null, null); + Travel travel = new Travel(travelId, member, null, null, 0, false); + + Comment mockComment = new Comment("content", member, travel, null); + + when(memberReader.getMember(memberId)).thenReturn(member); + when(travelReader.getTravel(travelId)).thenReturn(travel); + when(commentReader.findComment(commentCreateRequest.getParentId())).thenReturn( + Optional.empty()); + when(commentStore.save(any())).thenReturn(mockComment); + + var result = commentService.createComment(memberId, travelId, commentCreateRequest); + + verify(commentCountCacheStore).increaseCommentCount(travelId); + assertThat(result).isInstanceOf(CommentNotiInfo.class); + assertThat(result.getNotificationMemberId(memberId)).isNull(); + } + + @DisplayName("createComment 메서드가 CommentNotiInfo를 잘 반환하는지") + @Test + void whenCreateComment_thenShouldReturnCommentNotiInfoAndItContainsParentCommentMemberId() { + Long memberId = 1L; + Long travelId = 1L; + Long parentCommentMemberId = 500L; + Long parentCommentId = 3L; + + CommentCommand.CommentCreateRequest commentCreateRequest = new CommentCommand.CommentCreateRequest( + "content", parentCommentId); + Member parentCommentMember = new Member(parentCommentMemberId, null, null, null, null, + null); + + Member member = new Member(memberId, null, null, null, null, null); + Travel travel = new Travel(travelId, member, null, null, 0, false); + + Comment parentComment = new Comment("content", parentCommentMember, travel, null); + Comment mockComment = new Comment("content", member, travel, parentComment); + + when(memberReader.getMember(memberId)).thenReturn(member); + when(travelReader.getTravel(travelId)).thenReturn(travel); + when(commentReader.findComment(commentCreateRequest.getParentId())).thenReturn( + Optional.empty()); + when(commentStore.save(any())).thenReturn(mockComment); + + var result = commentService.createComment(memberId, travelId, commentCreateRequest); + + verify(commentCountCacheStore).increaseCommentCount(travelId); + assertThat(result).isInstanceOf(CommentNotiInfo.class); + assertThat(result.getNotificationMemberId(memberId)).isEqualTo(parentCommentMemberId); + } + + + @DisplayName("deleteComment 메서드가 오류없이 정상적으로 잘 작동하는지") + @Test + void whenDeleteComment_thenShouldNotThrowAnError() { + + Travel travel = new Travel(1L, null, null, null, 0, false); + Comment comment = new Comment("content", null, travel, null); + when(commentReader.getCommentWithMemberId(1L, 1L)).thenReturn(comment); + when(commentReader.getTravelIdByCommentId(1L)).thenReturn(1L); + + commentService.deleteComment(1L, 1L); + + verify(commentStore).delete(comment); + verify(commentCountCacheStore).decreaseCommentCount(1L); + } + + @DisplayName("getParentComments 메서드가 CommentsResponse를 잘 반환하는지") + @Test + void whenGetParentComments_thenShouldReturnCommentsResponse() { + Long travelId = 1L; + var pageable = PageRequest.of(0, 10); + + Member member1 = mock(Member.class); + Member member2 = mock(Member.class); + + Comment mockComment1 = new Comment("content1", member1, null, null); + Comment mockComment2 = new Comment("content2", member2, null, null); + + int childCount1 = 5; + int childCount2 = 3; + + Slice mockSlice = new SliceImpl<>(List.of(mockComment1, mockComment2), pageable, + true); + when(commentReader.getParentCommentsByTravelId(travelId, pageable)).thenReturn(mockSlice); + when(commentReader.getChildrenCommentCount(mockComment1.getId())).thenReturn(childCount1); + when(commentReader.getChildrenCommentCount(mockComment2.getId())).thenReturn(childCount2); + + var result = commentService.getParentComments(travelId, pageable); + + assertThat(result).isInstanceOf(CommentInfo.CommentsResponse.class); + assertThat(result.getContent()).hasSize(2); + } + + @DisplayName("getChildComments 메서드가 CommentsResponse를 잘 반환하는지") + @Test + void whenGetChildComments_thenShouldReturnCommentResponse() { + Long parentId = 1L; + var pageable = PageRequest.of(0, 10); + + Member member1 = mock(Member.class); + Member member2 = mock(Member.class); + Comment mockParentComment = mock(Comment.class); + Comment mockComment1 = new Comment("content1", member1, null, mockParentComment); + Comment mockComment2 = new Comment("content2", member2, null, mockParentComment); + Slice mockSlice = new SliceImpl<>(List.of(mockComment1, mockComment2), pageable, + true); + + when(commentReader.getChildCommentsByParentId(parentId, pageable)).thenReturn(mockSlice); + + var result = commentService.getChildComments(parentId, pageable); + + assertThat(result).isInstanceOf(CommentInfo.CommentsResponse.class); + assertThat(result.getContent()).hasSize(2); + } + + @DisplayName("updateComment 메서드가 CommentNotiInfo를 잘 반환하는지") + @Test + void whenUpdateComment_thenShouldReturnCommentNotiInfo() { + Long commentId = 1L; + Long memberId = 1L; + CommentCommand.CommentUpdateRequest command = new CommentCommand.CommentUpdateRequest( + "content"); + + Member sameMember = new Member(memberId, null, null, null, null, null); + Travel mockTravel = new Travel(1L, sameMember, null, null, 0, false); + Comment mockComment = new Comment("content", sameMember, mockTravel, null); + + when(commentReader.getCommentWithMemberId(commentId, memberId)).thenReturn(mockComment); + + var result = commentService.updateComment(commentId, memberId, command); + + assertThat(result).isInstanceOf(CommentNotiInfo.class); + assertThat(result.getNotificationMemberId(memberId)).isNull(); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/comment/infrastructure/CommentReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/comment/infrastructure/CommentReaderImplTest.java new file mode 100644 index 000000000..5e4feb1eb --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/comment/infrastructure/CommentReaderImplTest.java @@ -0,0 +1,123 @@ +package kr.co.yigil.comment.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Optional; +import kr.co.yigil.comment.domain.Comment; +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.Pageable; +import org.springframework.data.domain.SliceImpl; + +@ExtendWith(MockitoExtension.class) +class CommentReaderImplTest { + + @Mock + private CommentRepository commentRepository; + @InjectMocks + private CommentReaderImpl commentReader; + + @DisplayName("findComment 메서드가 Comment의 Optional 객체를 잘 반환하는지") + @Test + void whenFindComment_thenShouldReturnOptionalComment() { + + Comment mockComment = mock(Comment.class); + when(commentRepository.findById(1L)).thenReturn(Optional.of(mockComment)); + + var result = commentReader.findComment(1L); + assertThat(result).isPresent() + .isEqualTo(Optional.of(mockComment)); + } + + @DisplayName("getCommentWithMemberId 메서드가 Comment를 잘 반환하는지") + @Test + void whenGetCommentWithMemberId_thenShouldReturnComment() { + Comment mockComment = mock(Comment.class); + when(commentRepository.findByIdAndMemberId(1L, 1L)).thenReturn(Optional.of(mockComment)); + + var result = commentReader.getCommentWithMemberId(1L, 1L); + assertThat(result).isEqualTo(mockComment); + } + + @DisplayName("getCommentsByTravelId 메서드가 Slice를 잘 반환하는지") + @Test + void whenGetCommentsByTravelId_thenShouldRerturnCommentOfSlice() { + + when(commentRepository.findAllByTravelIdAndParentIsNull(anyLong(), any(Pageable.class))) + .thenReturn(new SliceImpl<>(Arrays.asList(mock(Comment.class)))); + + var result = commentReader.getCommentsByTravelId(1L, Pageable.unpaged()); + + assertThat(result).isNotNull(); + assertThat(result.getContent().getFirst()).isInstanceOf(Comment.class); + + } + + @DisplayName("getParentCommentsByTravelId 메서드가 Slice를 잘 반환하는지") + @Test + void whenGetParentCommentsByTravelId_thenShouldReturnCommentOfSlice() { + when(commentRepository.findAllByTravelIdAndParentIsNull(anyLong(), any(Pageable.class))) + .thenReturn(new SliceImpl<>(Arrays.asList(mock(Comment.class)))); + + var result = commentReader.getParentCommentsByTravelId(1L, Pageable.unpaged()); + + assertThat(result).isNotNull(); + assertThat(result.getContent().getFirst()).isInstanceOf(Comment.class); + } + + @DisplayName("getChildCommentsByParentId 메서드가 Slice를 잘 반환하는지") + @Test + void whenGetChildCommentsByParentId_thenShouldReturnCommentOfSlice() { + when(commentRepository.findChildCommentsByParentId(anyLong(), any(Pageable.class)) + ).thenReturn(new SliceImpl<>(Arrays.asList(mock(Comment.class)))); + + var result = commentReader.getChildCommentsByParentId(1L, Pageable.unpaged()); + + assertThat(result).isNotNull(); + assertThat(result.getContent().getFirst()).isInstanceOf(Comment.class); + } + + @DisplayName("getCommentCount 메서드가 travel의 댓글 수를 잘 반환하는지") + @Test + void whenGetCommentCount_thenShouldReturnTravelsCommentCounts() { + int commentCount = 100; + when(commentRepository.countAllByTravelIdAndIsDeletedFalse(anyLong())).thenReturn(commentCount); + + var result = commentReader.getCommentCount(1L); + + assertThat(result).isEqualTo(commentCount); + + } + + @DisplayName("getTravelIdByCommentId 메서드가 comment가 달린 글의 Id를 잘 반환하는지") + @Test + void whenGetTravelIdByCommentId_thenShouldReturnTravelId() { + Long travelId = 1L; + when(commentRepository.findTravelIdByCommentId(anyLong())).thenReturn(Optional.of(travelId)); + + var result = commentReader.getTravelIdByCommentId(1L); + + assertThat(result).isEqualTo(travelId); + + } + + @DisplayName("getChildrenCommentCount 메서드가 자식 댓글의 수를 잘 반환하는지") + @Test + void whenGetChildrenCommentCount_thenShouldReturn() { + int childrenCommentCount = 100; + when(commentRepository.countByParentId(anyLong())).thenReturn(childrenCommentCount); + + var result = commentReader.getChildrenCommentCount(1L); + + assertThat(result).isEqualTo(childrenCommentCount); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/comment/infrastructure/CommentStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/comment/infrastructure/CommentStoreImplTest.java new file mode 100644 index 000000000..49ef34c85 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/comment/infrastructure/CommentStoreImplTest.java @@ -0,0 +1,53 @@ +package kr.co.yigil.comment.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import kr.co.yigil.comment.domain.Comment; +import kr.co.yigil.global.exception.BadRequestException; +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; + + +@ExtendWith(MockitoExtension.class) +class CommentStoreImplTest { + @InjectMocks + private CommentStoreImpl commentStore; + @Mock + private CommentRepository commentRepository; + + @DisplayName("save 메서드가 잘 동작하는지") + @Test + void WhenSave_thenShouldReturnSavedComment() { + Comment comment = mock(Comment.class); + when(commentRepository.save(comment)).thenReturn(comment); + + Comment savedComment = commentStore.save(comment); + + assertThat(savedComment).isEqualTo(comment); + } + + @DisplayName("delete 메서드가 잘 동작하는지") + @Test + void whenDelete_thenShouldNotThrownAnError() { + Comment comment = mock(Comment.class); + when(comment.isDeleted()).thenReturn(false); + + commentStore.delete(comment); + } + + @DisplayName("delete 메서드가 삭제된 댓글을 삭제하려고 할 때 예외를 잘 발생시키는지") + @Test + void whenDelete_thenShouldThrownAnError() { + Comment comment = mock(Comment.class); + when(comment.isDeleted()).thenReturn(true); + + assertThatThrownBy(() -> commentStore.delete(comment)).isInstanceOf(BadRequestException.class); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/comment/interfaces/controller/CommentApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/comment/interfaces/controller/CommentApiControllerTest.java new file mode 100644 index 000000000..ef0d332df --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/comment/interfaces/controller/CommentApiControllerTest.java @@ -0,0 +1,275 @@ +package kr.co.yigil.comment.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +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.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.comment.application.CommentFacade; +import kr.co.yigil.comment.domain.CommentCommand; +import kr.co.yigil.comment.domain.CommentInfo; +import kr.co.yigil.comment.interfaces.dto.CommentDto; +import kr.co.yigil.comment.interfaces.dto.CommentDto.CommentCreateResponse; +import kr.co.yigil.comment.interfaces.dto.mapper.CommentMapper; +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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.config.EnableSpringDataWebSupport; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(CommentApiController.class) +@EnableSpringDataWebSupport +class CommentApiControllerTest { + + @MockBean + private CommentFacade commentFacade; + @MockBean + private CommentMapper commentMapper; + + private MockMvc mockMvc; + ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation) + .uris() + .withScheme("https") + .withHost("yigil.co.kr") + .withPort(80) + ) + .build(); + } + + @DisplayName("comment 생성 요청이 왔을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenCreateComment_thenReturns200AndCommentCreateResponse() throws Exception { + CommentDto.CommentCreateResponse mockResponse = new CommentCreateResponse("message"); + + Accessor accessor = Accessor.member(1L); + + CommentDto.CommentCreateRequest request = new CommentDto.CommentCreateRequest("content", 1L); + + String json = objectMapper.writeValueAsString(request); + + when(commentMapper.of(any(CommentDto.CommentCreateRequest.class))).thenReturn( + mock(CommentCommand.CommentCreateRequest.class)); + + mockMvc.perform(post("/api/v1/comments/travels/1") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .sessionAttr("memberId", accessor.getMemberId())) + .andExpect(status().isOk()) + .andDo(document( + "comments/comment-create", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("content").description("댓글 내용"), + fieldWithPath("parentId").description("부모 댓글 id") + ), + responseFields( + fieldWithPath("message").description("응답 메시지") + ) + )); + + verify(commentFacade).createComment(anyLong(), anyLong(), any(CommentCommand.CommentCreateRequest.class)); + } + + @DisplayName("부모 댓글 조회 요청이 왔을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenGetParentCommentList_thenReturns200AndCommentResponse() throws Exception { + + CommentDto.CommentsUnitInfo mockUnitInfo1 = new CommentDto.CommentsUnitInfo( + 1L, "content", 1L, "nickname", "http://yigil.co.kr/images/profile.jpg", 3, + "2024-03-01"); + + CommentDto.CommentsUnitInfo mockUnitInfo2 = new CommentDto.CommentsUnitInfo( + 2L, "content2", 1L, "nickname3", "http://yigil.co.kr/images/profile3.jpg", 1, + "2024-03-01"); + List commentsUnitInfoList = List.of(mockUnitInfo1, + mockUnitInfo2); + + CommentDto.CommentsResponse mockResponse = new CommentDto.CommentsResponse( + commentsUnitInfoList, false); + + when(commentFacade.getParentCommentList(anyLong(), any(Pageable.class))).thenReturn( + mock(CommentInfo.CommentsResponse.class)); + when(commentMapper.of(any(CommentInfo.CommentsResponse.class))).thenReturn( + mockResponse); + + mockMvc.perform(get("/api/v1/comments/travels/{travelId}" ,1L) + .param("page", "1") + .param("size", "5") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document( + "comments/comment-get-parent", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("travelId").description("본문 id") + ), + queryParameters( + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + responseFields( + subsectionWithPath("content").description("comment의 정보"), + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("댓글 id"), + fieldWithPath("content[].content").type(JsonFieldType.STRING).description("댓글 내용"), + fieldWithPath("content[].member_id").type(JsonFieldType.NUMBER).description("회원 id"), + fieldWithPath("content[].member_nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("content[].member_image_url").type(JsonFieldType.STRING).description("프로필 이미지 url"), + fieldWithPath("content[].child_count").type(JsonFieldType.NUMBER).description("자식 댓글 수"), + fieldWithPath("content[].created_at").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("has_next").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부") + ) + )); + } + + @DisplayName("대댓글 조회 요청이 왔을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenGetChildCommentList_thenReturns200AndCommentResponse() throws Exception { + + CommentDto.CommentsUnitInfo mockUnitInfo1 = new CommentDto.CommentsUnitInfo( + 1L, "content", 1L, "nickname", "http://yigil.co.kr/images/profile.jpg", 0, + "2024-03-01"); + + CommentDto.CommentsUnitInfo mockUnitInfo2 = new CommentDto.CommentsUnitInfo( + 2L, "content2", 1L, "nickname3", "http://yigil.co.kr/images/profile3.jpg", 0, + "2024-03-01"); + List commentsUnitInfoList = List.of(mockUnitInfo1, + mockUnitInfo2); + + CommentDto.CommentsResponse mockResponse = new CommentDto.CommentsResponse( + commentsUnitInfoList, false); + + when(commentFacade.getChildCommentList(anyLong(), any(Pageable.class))).thenReturn( + mock(CommentInfo.CommentsResponse.class)); + when(commentMapper.of(any(CommentInfo.CommentsResponse.class))).thenReturn( + mockResponse); + + mockMvc.perform(get("/api/v1/comments/parents/{parentId}" ,1L) + .param("page", "1") + .param("size", "5") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document( + "comments/comment-get-child", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("parentId").description("부모 댓글 id") + ), + queryParameters( + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + responseFields( + + subsectionWithPath("content").description("comment의 정보"), + fieldWithPath("content[].id").type(JsonFieldType.NUMBER).description("댓글 id"), + fieldWithPath("content[].content").type(JsonFieldType.STRING).description("댓글 내용"), + fieldWithPath("content[].member_id").type(JsonFieldType.NUMBER).description("회원 id"), + fieldWithPath("content[].member_nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("content[].member_image_url").type(JsonFieldType.STRING).description("프로필 이미지"), + fieldWithPath("content[].child_count").type(JsonFieldType.NUMBER).description("자식 댓글 수"), + fieldWithPath("content[].created_at").type(JsonFieldType.STRING).description("생성일"), + fieldWithPath("has_next").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부") + ) + )); + } + + @DisplayName("comment 수정 요청이 왔을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenUpdateComment_thenReturns200AndCommentUpdateResponse() throws Exception { + CommentDto.CommentUpdateRequest request = new CommentDto.CommentUpdateRequest("content"); + + String json = objectMapper.writeValueAsString(request); + + when(commentMapper.of(any(CommentDto.CommentUpdateRequest.class))).thenReturn( + mock(CommentCommand.CommentUpdateRequest.class)); + + mockMvc.perform(post("/api/v1/comments/{commentId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andDo(document( + "comments/comment-update", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("commentId").description("댓글 id") + ), + requestFields( + fieldWithPath("content").description("댓글 내용") + ), + responseFields( + fieldWithPath("message").description("응답 메시지") + ) + )); + + verify(commentFacade).updateComment(anyLong(), anyLong(), any(CommentCommand.CommentUpdateRequest.class)); + } + + @DisplayName("comment 삭제 요청이 왔을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenDeleteComment_thenReturns200AndCommentDeleteResponse() throws Exception { + + CommentDto.CommentDeleteResponse mockResponse = new CommentDto.CommentDeleteResponse("댓글 삭제 성공"); + + when(commentMapper.of(any(CommentInfo.DeleteResponse.class))).thenReturn( + mockResponse); + + mockMvc.perform(delete("/api/v1/comments/{commentId}", 1L) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document( + "comments/comment-delete", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("commentId").description("댓글 id") + ), + responseFields( + fieldWithPath("message").description("응답 메시지") + ) + )); + + verify(commentFacade).deleteComment(anyLong(), anyLong()); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/comment/presentation/CommentControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/comment/presentation/CommentControllerTest.java deleted file mode 100644 index ec0998323..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/comment/presentation/CommentControllerTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package kr.co.yigil.comment.presentation; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.comment.application.CommentService; -import kr.co.yigil.comment.dto.request.CommentCreateRequest; -import kr.co.yigil.comment.dto.response.CommentCreateResponse; -import kr.co.yigil.comment.dto.response.CommentDeleteResponse; -import kr.co.yigil.comment.dto.response.CommentResponse; -import kr.co.yigil.travel.application.SpotService; -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.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@WebMvcTest(CommentController.class) -@ExtendWith(MockitoExtension.class) -class CommentControllerTest { - - private MockMvc mockMvc; - - @MockBean - private CommentService commentService; - - @InjectMocks - private CommentController commentController; - - @BeforeEach - public void setup(WebApplicationContext webApplicationContext){ - this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } - - @DisplayName("comment 생성 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenCreateComment_thenReturns200AndCommentCreateResponse() throws Exception { - CommentCreateResponse mockResponse = new CommentCreateResponse(); - Accessor accessor = Accessor.member(1L); - - CommentCreateRequest request = new CommentCreateRequest("content", 1L, 1L); - - given(commentService.createComment(anyLong(), anyLong(), any(CommentCreateRequest.class))).willReturn(mockResponse); - - mockMvc.perform(post("/api/v1/comments/1") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(request)) - .sessionAttr("memberId", accessor.getMemberId())) - .andExpect(status().isOk()); - } - @DisplayName("댓글 조회 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenGetTopCommentList_thenReturns200AndCommentResponse() throws Exception { - CommentResponse mockCommentResponse1 = new CommentResponse(); - CommentResponse mockCommentResponse2 = new CommentResponse(); - - given(commentService.getTopLevelCommentList(anyLong())).willReturn( - List.of(mockCommentResponse1, mockCommentResponse2)); - - mockMvc.perform(get("/api/v1/comments/1") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @DisplayName("대댓글 조회 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenGetReplyCommentList_thenReturns200AndCommentResponse() throws Exception { - CommentResponse mockCommentResponse1 = new CommentResponse(); - CommentResponse mockCommentResponse2 = new CommentResponse(); - - given(commentService.getReplyCommentList(anyLong(), anyLong())).willReturn(List.of(mockCommentResponse1, mockCommentResponse2)); - - mockMvc.perform(get("/api/v1/comments/1/1") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @DisplayName("comment 삭제 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenDeleteComment_thenReturns200AndCommentDeleteResponse() throws Exception { - CommentDeleteResponse mockResponse = new CommentDeleteResponse(); - - given(commentService.deleteComment(anyLong(), anyLong(), anyLong())).willReturn(mockResponse); - - mockMvc.perform(delete("/api/v1/comments/1/1") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/favor/application/FavorFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/favor/application/FavorFacadeTest.java new file mode 100644 index 000000000..4a69ff31b --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/favor/application/FavorFacadeTest.java @@ -0,0 +1,56 @@ +package kr.co.yigil.favor.application; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.favor.domain.FavorInfo; +import kr.co.yigil.favor.domain.FavorService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +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; + +@ExtendWith(MockitoExtension.class) +class FavorFacadeTest { + + @Mock + private FavorService favorService; + @Mock + private NotificationService notificationService; + + @InjectMocks + private FavorFacade favorFacade; + + @DisplayName("좋아요 추가가 잘 되는지") + @Test + void WhenAddFavor_ThenShouldReturnAddFavorResponse() { + Long memberId = 1L; + Long ownerId = 2L; + Long travelId = 1L; + + when(favorService.addFavor(memberId, travelId)).thenReturn(ownerId); + + var response = favorFacade.addFavor(memberId, travelId); + + verify(notificationService).sendNotification(NotificationType.FAVOR, memberId, ownerId); + assertThat(response).isInstanceOf(FavorInfo.AddFavorResponse.class); + } + + + @DisplayName("좋아요 삭제가 잘 되는지") + @Test + void WhenDeleteFavor_ThenShouldReturnDeleteFavorResponse() { + Long memberId = 1L; + Long travelId = 1L; + + var response = favorFacade.deleteFavor(memberId, travelId); + + verify(favorService).deleteFavor(memberId, travelId); + assertThat(response).isInstanceOf(FavorInfo.DeleteFavorResponse.class); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/favor/application/FavorRedisIntegrityServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/favor/application/FavorRedisIntegrityServiceTest.java deleted file mode 100644 index 0932093d3..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/favor/application/FavorRedisIntegrityServiceTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package kr.co.yigil.favor.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import kr.co.yigil.favor.domain.FavorCount; -import kr.co.yigil.favor.domain.repository.FavorCountRepository; -import kr.co.yigil.favor.domain.repository.FavorRepository; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.travel.domain.Spot; -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; - -public class FavorRedisIntegrityServiceTest { - - @Mock - private FavorRepository favorRepository; - - @Mock - private FavorCountRepository favorCountRepository; - - @InjectMocks - private FavorRedisIntegrityService favorRedisIntegrityService; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - } - - @DisplayName("ensureFavorCounts 메서드가 이미 존재하는 FavorCount를 잘 반환하는지") - @Test - void testEnsureLikeCountsWhenAlreadyExists() { - Member member = new Member(1L, "email", "12345678", "member", "image.jpg", SocialLoginType.KAKAO); - Spot spot = new Spot(1L); - Post post = new Post(1L, spot, member); - FavorCount existingFavorCount = new FavorCount(1L, 10); - - when(favorCountRepository.findById(post.getId())).thenReturn(Optional.of(existingFavorCount)); - - FavorCount result = favorRedisIntegrityService.ensureFavorCounts(post); - - assertThat(result).isEqualTo(existingFavorCount); - verify(favorRepository, never()).countByPostId(post.getId()); - verify(favorCountRepository, never()).save(any()); - } - - @DisplayName("ensureLikeCounts 메서드가 존재하지 않을 경우 LikeCount를 생성하고 저장하는지") - @Test - void testEnsureLikeCountsWhenNotExists() { - Member member = new Member(1L, "email", "12345678", "member", "image.jpg", SocialLoginType.KAKAO); - Spot spot = new Spot(1L); - Post post = new Post(1L, spot, member); - FavorCount newFavorCount = new FavorCount(1L, 10); - - when(favorCountRepository.findById(post.getId())).thenReturn(Optional.empty()); - when(favorRepository.countByPostId(post.getId())).thenReturn(10); - when(favorCountRepository.save(any())).thenReturn(newFavorCount); - - FavorCount count = favorRedisIntegrityService.ensureFavorCounts(post); - - assertThat(count.getFavorCount()).isEqualTo(newFavorCount.getFavorCount()); - verify(favorRepository).countByPostId(post.getId()); - verify(favorCountRepository).save(any()); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/favor/application/FavorServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/favor/application/FavorServiceTest.java deleted file mode 100644 index 1df3baf6c..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/favor/application/FavorServiceTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package kr.co.yigil.favor.application; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.favor.domain.Favor; -import kr.co.yigil.favor.domain.FavorCount; -import kr.co.yigil.favor.domain.repository.FavorCountRepository; -import kr.co.yigil.favor.domain.repository.FavorRepository; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.member.domain.repository.MemberRepository; -import kr.co.yigil.notification.application.NotificationService; -import kr.co.yigil.notification.domain.Notification; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.post.domain.repository.PostRepository; -import kr.co.yigil.travel.domain.Spot; -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; - -public class FavorServiceTest { - - @Mock - private MemberRepository memberRepository; - - @Mock - private PostRepository postRepository; - - @Mock - private FavorCountRepository favorCountRepository; - - @Mock - private FavorRepository favorRepository; - - @Mock - private NotificationService notificationService; - - @Mock - private FavorRedisIntegrityService favorRedisIntegrityService; - - @InjectMocks - private FavorService favorService; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - } - - @DisplayName("addFavor 메서드가 좋아요를 잘 저장하고 알림을 잘 보내는지") - @Test - void whenAddFavor_ShouldSaveFavorAndSendNotification() { - Long memberId = 1L; - Long postId = 1L; - Member member = new Member(memberId, "email", "12345678", "member", "image.jpg", SocialLoginType.KAKAO); - Spot spot = new Spot(1L); - Post post = new Post(postId, spot, member); - FavorCount favorCount = new FavorCount(postId, 1); - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - when(favorCountRepository.findById(postId)).thenReturn(Optional.of(favorCount)); - when(favorRedisIntegrityService.ensureFavorCounts(post)).thenReturn(favorCount); - - favorService.addFavor(memberId, postId); - - verify(favorRepository, times(1)).save(any(Favor.class)); - verify(notificationService, times(1)).sendNotification(any(Notification.class)); - } - - @DisplayName("addFavor 메서드가 존재하지 않는 게시글에 대한 요청을 보냈을 때 예외가 잘 발생하는지") - @Test - void whenAddFavorWithInvalidPostInfo_ShouldThrowException() { - Long nonExisitingId = 3L; - - when(postRepository.findById(nonExisitingId)).thenReturn(Optional.empty()); - - assertThrows(BadRequestException.class, () -> favorService.addFavor(1L, nonExisitingId)); - } - - @DisplayName("deleteFavor 메서드가 좋아요 정보를 잘 삭제하는지") - @Test - void whenDeleteFavor_ShouldDeleteFavor() { - Long memberId = 1L; - Long postId = 1L; - Member member = new Member(memberId, "email", "12345678", "member", "image.jpg", SocialLoginType.KAKAO); - Spot spot = new Spot(1L); - Post post = new Post(postId, spot, member); - FavorCount favorCount = new FavorCount(postId, 1); - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - when(favorCountRepository.findById(postId)).thenReturn(Optional.of(favorCount)); - when(favorRedisIntegrityService.ensureFavorCounts(post)).thenReturn(favorCount); - - favorService.deleteFavor(memberId, postId); - - verify(favorRepository, times(1)).deleteByMemberAndPost(any(Member.class), any(Post.class)); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/favor/domain/FavorServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/favor/domain/FavorServiceImplTest.java new file mode 100644 index 000000000..0a9675a50 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/favor/domain/FavorServiceImplTest.java @@ -0,0 +1,72 @@ +package kr.co.yigil.favor.domain; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.travel.domain.Travel; +import kr.co.yigil.travel.domain.TravelReader; +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; + +@ExtendWith(MockitoExtension.class) +class FavorServiceImplTest { + + @InjectMocks + private FavorServiceImpl favorService; + + @Mock + private FavorReader favorReader; + @Mock + private MemberReader memberReader; + + @Mock + private FavorStore favorStore; + @Mock + private TravelReader travelReader; + @Mock + private FavorCountCacheStore favorCountCacheStore; + + @DisplayName("addFavor 를 호출했을 때 좋아요가 잘 추가되는지 확인") + @Test + void WhenAddFavor_ThenShouldReturnOwnersId() { + Long memberId = 1L; + Long travelId = 1L; + + Member mockMember = new Member(1L, null, null, null, null, null); + Travel mockTravel = new Travel(1L, mockMember, null, null, 0, false); + + when(favorReader.existsByMemberIdAndTravelId(memberId, travelId)).thenReturn(false); + when(memberReader.getMember(memberId)).thenReturn(mockMember); + when(travelReader.getTravel(travelId)).thenReturn(mockTravel); + + favorService.addFavor(memberId, travelId); + + verify(favorStore).save(any(Favor.class)); + verify(favorCountCacheStore).incrementFavorCount(travelId); + } + + @DisplayName("deleteFavor 를 호출했을 때 좋아요가 잘 삭제되는지 확인") + @Test + void WhenDeleteFavor_ThenShouldNotThrowError() { + Member member = mock(Member.class); + when(memberReader.getMember(1L)).thenReturn(member); + Travel travel = mock(Travel.class); + when(travelReader.getTravel(1L)).thenReturn(travel); + + when(favorReader.getFavorIdByMemberAndTravel(member, travel)).thenReturn(1L); + + favorService.deleteFavor(1L, 1L); + + verify(favorStore).deleteFavorById(1L); + verify(favorCountCacheStore).decrementFavorCount(1L); + + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/favor/infrastructure/FavorReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/favor/infrastructure/FavorReaderImplTest.java new file mode 100644 index 000000000..3239cdb7b --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/favor/infrastructure/FavorReaderImplTest.java @@ -0,0 +1,46 @@ +package kr.co.yigil.favor.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import kr.co.yigil.favor.domain.repository.FavorRepository; +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; + + +@ExtendWith(MockitoExtension.class) +class FavorReaderImplTest { + + @Mock + private FavorRepository favorRepository; + + @InjectMocks + private FavorReaderImpl favorReader; + + @DisplayName("유효한 파라미터를 전달했을 때 existsByMemberIdAndTravelId 메서드가 올바른 결과를 반환하는지") + @Test + void GivenValidParameters_WhenCallExistsByMemberIdAndTravelId_ThenShouldReturnBoolean() { + when(favorRepository.existsByMemberIdAndTravelId(anyLong(), anyLong())).thenReturn(true); + + var result = favorReader.existsByMemberIdAndTravelId(1L, 1L); + + assertThat(result).isTrue(); + } + + @DisplayName("유효하지 않은 파라미터를 전달했을 때 existsByMemberIdAndTravelId 메서드가 올바른 결과를 반환하는지") + @Test + void GivenInvalidParameters_WhenCallExistsByMemberIdAndTravelId_ThenShouldReturnBoolean() { + when(favorRepository.existsByMemberIdAndTravelId(anyLong(), anyLong())).thenReturn(false); + + var result = favorReader.existsByMemberIdAndTravelId(1L, 1L); + + assertThat(result).isFalse(); + } + +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/favor/infrastructure/FavorStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/favor/infrastructure/FavorStoreImplTest.java new file mode 100644 index 000000000..eb18ed34c --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/favor/infrastructure/FavorStoreImplTest.java @@ -0,0 +1,36 @@ +package kr.co.yigil.favor.infrastructure; + +import static org.mockito.Mockito.verify; + +import kr.co.yigil.favor.domain.repository.FavorRepository; +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; + +@ExtendWith(MockitoExtension.class) +class FavorStoreImplTest { + + @Mock + private FavorRepository favorRepository; + @InjectMocks + private FavorStoreImpl favorStore; + + @DisplayName("save 메서드가 Favor를 잘 저장하는지") + @Test + void WhenSaveFavor_ThenShouldNotThrowAnError() { + favorStore.save(null); + + verify(favorRepository).save(null); + } + + @DisplayName("deleteFavorById 메서드가 Favor를 잘 삭제하는지") + @Test + void WhenDeleteFavorById_ThenShouldNotThrowAnError() { + favorStore.deleteFavorById(1L); + + verify(favorRepository).deleteById(1L); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/favor/interfaces/controller/FavorApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/favor/interfaces/controller/FavorApiControllerTest.java new file mode 100644 index 000000000..ee26ca0a5 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/favor/interfaces/controller/FavorApiControllerTest.java @@ -0,0 +1,122 @@ +package kr.co.yigil.favor.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import kr.co.yigil.favor.application.FavorFacade; +import kr.co.yigil.favor.domain.FavorInfo; +import kr.co.yigil.favor.domain.FavorInfo.AddFavorResponse; +import kr.co.yigil.favor.domain.FavorInfo.DeleteFavorResponse; +import kr.co.yigil.favor.interfaces.dto.FavorDto; +import kr.co.yigil.favor.interfaces.dto.mapper.FavorMapper; +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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.web.config.EnableSpringDataWebSupport; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(FavorApiController.class) +@EnableSpringDataWebSupport +class FavorApiControllerTest { + + @MockBean + private FavorFacade favorFacade; + + @MockBean + private FavorMapper favorMapper; + + private MockMvc mockMvc; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation) + .uris() + .withScheme("https") + .withHost("yigil.co.kr") + .withPort(80) + ) + .build(); + } + + @DisplayName("좋아요 추가가 잘 되는지") + @Test + void WhenAddFavor_ThenShouldReturnOk() throws Exception { + FavorDto.AddFavorResponse response = FavorDto.AddFavorResponse.builder() + .message("좋아요가 완료되었습니다.") + .build(); + + Long memberId = 1L; + Long travelId = 1L; + + FavorInfo.AddFavorResponse addFavorResponse = new FavorInfo.AddFavorResponse("좋아요가 완료되었습니다."); + + when(favorFacade.addFavor(anyLong(), anyLong())).thenReturn(addFavorResponse); + when(favorMapper.of(any(AddFavorResponse.class))).thenReturn(response); + + mockMvc.perform(post("/api/v1/like/{travelId}", travelId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("likes/add-favor", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("travelId").description("좋아요를 추가할 게시물의 ID") + ), + responseFields( + fieldWithPath("message").description("좋아요가 완료되었습니다.") + ) + )); + } + + @DisplayName("좋아요 취소 잘 되는지") + @Test + void WhenDeleteFavor_ThenShouldReturnOk() throws Exception{ + FavorDto.DeleteFavorResponse response = FavorDto.DeleteFavorResponse.builder() + .message("좋아요가 취소되었습니다.") + .build(); + + Long travelId = 1L; + + FavorInfo.DeleteFavorResponse deleteFavorResponse = new FavorInfo.DeleteFavorResponse("좋아요가 취소되었습니다."); + + when(favorFacade.deleteFavor(anyLong(), anyLong())).thenReturn(deleteFavorResponse); + when(favorMapper.of(any(DeleteFavorResponse.class))).thenReturn(response); + + mockMvc.perform(post("/api/v1/unlike/{travelId}", travelId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("likes/delete-favor", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("travelId").description("좋아요를 취소할 게시물의 ID") + ), + responseFields( + fieldWithPath("message").description("좋아요가 취소되었습니다.") + ) + )); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/favor/presentation/FavorControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/favor/presentation/FavorControllerTest.java deleted file mode 100644 index 9f161f1c2..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/favor/presentation/FavorControllerTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package kr.co.yigil.favor.presentation; - -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import kr.co.yigil.favor.application.FavorService; -import kr.co.yigil.favor.dto.response.AddFavorResponse; -import kr.co.yigil.favor.dto.response.DeleteFavorResponse; -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.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@ExtendWith(SpringExtension.class) -@WebMvcTest(FavorController.class) -public class FavorControllerTest { - - private MockMvc mockMvc; - - @MockBean - private FavorService favorService; - - @InjectMocks - private FavorController favorController; - - @BeforeEach - void setUp(WebApplicationContext webApplicationContext) { - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } - - @DisplayName("좋아요가 요청되었을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenAddFavor_thenReturns200AndAddFavorResponse() throws Exception { - Long accessorId = 1L; - Long postId = 1L; - AddFavorResponse mockResponse = new AddFavorResponse(); - - given(favorService.addFavor(accessorId, postId)).willReturn(mockResponse); - - mockMvc.perform(post("/api/v1/like/" + postId) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @DisplayName("좋아요 취소가 요청되었을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenDeleteFavor_thenReturns200AndDeleteFavorResponse() throws Exception { - Long accessorId = 1L; - Long postId = 1L; - DeleteFavorResponse mockResponse = new DeleteFavorResponse(); - - given(favorService.deleteFavor(accessorId, postId)).willReturn(mockResponse); - - mockMvc.perform(post("/api/v1/unlike/" + postId) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/file/FileUploadEventListenerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/file/FileUploadEventListenerTest.java index 56dea946e..8a354bc54 100644 --- a/backend/yigil-api/src/test/java/kr/co/yigil/file/FileUploadEventListenerTest.java +++ b/backend/yigil-api/src/test/java/kr/co/yigil/file/FileUploadEventListenerTest.java @@ -1,6 +1,5 @@ package kr.co.yigil.file; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -43,7 +42,8 @@ public void setUp() { @Test void shouldUploadFileToS3() throws IOException { MultipartFile mockFile = new MockMultipartFile("file", "test.jpg", "image/jpeg", new byte[10]); - FileUploadEvent event = new FileUploadEvent(new Object(), mockFile, mock(Consumer.class)); + Consumer mockConsumer = mock(Consumer.class); + FileUploadEvent event = new FileUploadEvent(new Object(), mockFile, mockConsumer); fileUploadEventListener.handleFileUpload(event); verify(mockAmazonS3Client).putObject(eq("cdn.yigil.co.kr"), anyString(), any(), any( diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/file/FileUploadEventTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/file/FileUploadEventTest.java index 9383fc3fe..00436e762 100644 --- a/backend/yigil-api/src/test/java/kr/co/yigil/file/FileUploadEventTest.java +++ b/backend/yigil-api/src/test/java/kr/co/yigil/file/FileUploadEventTest.java @@ -18,8 +18,8 @@ public class FileUploadEventTest { @Test void shouldDetermineFileTypeAsImage() { MultipartFile file = new MockMultipartFile("image", "image.jpg", "image/jpeg", new byte[10]); - FileUploadEvent event = new FileUploadEvent(new Object(), file, - (java.util.function.Consumer) mock(Consumer.class)); + Consumer mockConsumer = mock(Consumer.class); + FileUploadEvent event = new FileUploadEvent(new Object(), file, mockConsumer); assertEquals(FileType.IMAGE, event.getFileType()); } @@ -28,23 +28,24 @@ void shouldDetermineFileTypeAsImage() { void shouldThrowExceptionForExceedingFileSize() { byte[] largeFileContent = new byte[10485761]; MultipartFile largeFile = new MockMultipartFile("image", "image.jpg", "image/jpeg", largeFileContent); - - assertThrows(FileException.class, () -> new FileUploadEvent(new Object(), largeFile, mock( - Consumer.class))); + Consumer mockConsumer = mock(Consumer.class); + assertThrows(FileException.class, () -> new FileUploadEvent(new Object(), largeFile, mockConsumer)); } @DisplayName("validFile이 유효한 파일을 잘 구별하는지") @Test void shouldProcessValidFile() { MultipartFile file = new MockMultipartFile("video", "video.mp4", "video/mp4", new byte[10]); - assertDoesNotThrow(() -> new FileUploadEvent(new Object(), file, mock(Consumer.class))); + Consumer mockConsumer = mock(Consumer.class); + assertDoesNotThrow(() -> new FileUploadEvent(new Object(), file, mockConsumer)); } @DisplayName("빈 파일이 들어왔을 때 FileException이 잘 발생하는지") @Test void shouldThrowExceptionForEmptyFileType() { MultipartFile file = new MockMultipartFile("empty", "", "", new byte[0]); - assertThrows(FileException.class, () -> new FileUploadEvent(new Object(), file, mock( - Consumer.class))); + Consumer mockConsumer = mock(Consumer.class); + + assertThrows(FileException.class, () -> new FileUploadEvent(new Object(), file, mockConsumer)); } } diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/application/FollowFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/application/FollowFacadeTest.java new file mode 100644 index 000000000..a15ee72e3 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/follow/application/FollowFacadeTest.java @@ -0,0 +1,99 @@ +package kr.co.yigil.follow.application; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import kr.co.yigil.follow.domain.FollowInfo; +import kr.co.yigil.follow.domain.FollowService; +import kr.co.yigil.notification.domain.NotificationService; +import kr.co.yigil.notification.domain.NotificationType; +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.PageRequest; +import org.springframework.data.domain.Pageable; + +public class FollowFacadeTest { + + @Mock + private FollowService followService; + + @Mock + private NotificationService notificationService; + + @InjectMocks + private FollowFacade followFacade; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("follow 메서드가 FollowService와 NotificationService의 메서드를 잘 호출하는지") + @Test + void whenFollow_thenCallsMethods() { + Long followerId = 1L; + Long followingId = 2L; + + followFacade.follow(followerId, followingId); + + verify(followService, times(1)).follow(followerId, followingId); + verify(notificationService, times(1)).sendNotification(NotificationType.FOLLOW, followerId, followingId); + } + + + @DisplayName("unfollow 메서드가 FollowService와 NotificationService의 메서드를 잘 호출하는지") + @Test + void whenUnfollow_thenCallsMethods() { + Long unfollowerId = 1L; + Long unfollowingId = 2L; + + followFacade.unfollow(unfollowerId, unfollowingId); + + verify(followService, times(1)).unfollow(unfollowerId, unfollowingId); + verify(notificationService, times(1)).sendNotification(NotificationType.UNFOLLOW, unfollowerId, unfollowingId); + } + + @DisplayName("getFollowerList 메서드가 유효한 요청이 들어왔을 때 MemberService의 getFollowerList 메서드를 잘 호출하는지") + @Test + void WhenGetFollowerList_ThenShouldReturnFollowerResponse() { + Long memberId = 1L; + PageRequest pageable = mock(PageRequest.class); + + FollowInfo.FollowersResponse mockFollowerResponse = new FollowInfo.FollowersResponse( + Collections.emptyList(), false); + when(followService.getFollowerList(memberId, pageable)).thenReturn(mockFollowerResponse); + + var result = followFacade.getFollowerList(memberId, pageable); + + assertThat(result).isNotNull() + .isInstanceOf(FollowInfo.FollowersResponse.class) + .usingRecursiveComparison().isEqualTo(mockFollowerResponse); + } + + @DisplayName("getFollowerList 메서드가 유효한 요청이 들어왔을 때 MemberService의 getFollowerList 메서드를 잘 호출하는지") + @Test + void WhenGetFollowingList_ThenShouldReturnFollowingResponse() { + Long memberId = 1L; + PageRequest pageable = mock(PageRequest.class); + + FollowInfo.FollowingsResponse mockFollowingResponse = new FollowInfo.FollowingsResponse( + Collections.emptyList(), false); + when(followService.getFollowingList(anyLong(), any(Pageable.class))).thenReturn(mockFollowingResponse); + + var result = followFacade.getFollowingList(memberId, pageable); + + assertThat(result).isNotNull() + .isInstanceOf(FollowInfo.FollowingsResponse.class) + .usingRecursiveComparison().isEqualTo(mockFollowingResponse); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/application/FollowRedisIntegrityServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/application/FollowRedisIntegrityServiceTest.java deleted file mode 100644 index 447977d74..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/follow/application/FollowRedisIntegrityServiceTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package kr.co.yigil.follow.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import kr.co.yigil.follow.domain.FollowCount; -import kr.co.yigil.follow.domain.repository.FollowCountRepository; -import kr.co.yigil.follow.domain.repository.FollowRepository; -import kr.co.yigil.follow.dto.FollowCountDto; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -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; - -public class FollowRedisIntegrityServiceTest { - - @Mock - private FollowRepository followRepository; - - @Mock - private FollowCountRepository followCountRepository; - - @InjectMocks - private FollowRedisIntegrityService followRedisIntegrityService; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - } - - @DisplayName("ensureFollowCounts 메서드가 이미 존재하는 FollowCount를 반환하는지") - @Test - void testEnsureFollowCountsWhenAlreadyExists() { - // Given - Member member = new Member(1L, "email", "12345678", "member", "image.jpg", SocialLoginType.KAKAO); - FollowCount existingFollowCount = new FollowCount(1L, 10, 5); - - when(followCountRepository.findById(member.getId())).thenReturn(Optional.of(existingFollowCount)); - - // When - FollowCount result = followRedisIntegrityService.ensureFollowCounts(member); - - // Then - assertThat(result).isEqualTo(existingFollowCount); - verify(followRepository, never()).getFollowCounts(any()); - verify(followCountRepository, never()).save(any()); - } - - @DisplayName("ensureFollowCounts 메서드가 존재하지 않을 경우 FollowCount를 생성하고 저장하는지") - @Test - void testEnsureFollowCountsWhenNotExists() { - // Given - Member member = new Member(1L, "email", "12345678", "member", "image.jpg", SocialLoginType.KAKAO); - FollowCountDto followCountDto = new FollowCountDto(10L, 5L); - FollowCount newFollowCount = new FollowCount(1L, 10, 5); - - when(followCountRepository.findById(member.getId())).thenReturn(Optional.empty()); - when(followRepository.getFollowCounts(member)).thenReturn(followCountDto); - when(followCountRepository.save(any())).thenReturn(newFollowCount); - - // When - FollowCount result = followRedisIntegrityService.ensureFollowCounts(member); - - // Then - assertThat(newFollowCount.getFollowerCount()).isEqualTo(result.getFollowerCount()); - assertThat(newFollowCount.getFollowingCount()).isEqualTo(result.getFollowingCount()); - verify(followRepository).getFollowCounts(member); - verify(followCountRepository).save((FollowCount) any()); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/application/FollowServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/application/FollowServiceTest.java deleted file mode 100644 index 2d7893a05..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/follow/application/FollowServiceTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package kr.co.yigil.follow.application; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import kr.co.yigil.follow.domain.Follow; -import kr.co.yigil.follow.domain.FollowCount; -import kr.co.yigil.follow.domain.repository.FollowCountRepository; -import kr.co.yigil.follow.domain.repository.FollowRepository; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.member.domain.repository.MemberRepository; -import kr.co.yigil.notification.application.NotificationService; -import kr.co.yigil.notification.domain.Notification; -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; - -public class FollowServiceTest { - - @Mock - private FollowRepository followRepository; - - @Mock - private FollowCountRepository followCountRepository; - - @Mock - private MemberRepository memberRepository; - - @Mock - private NotificationService notificationService; - - @Mock - private FollowRedisIntegrityService followRedisIntegrityService; - - @InjectMocks - private FollowService followService; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - } - - @DisplayName("follow 메서드가 팔로우를 잘 저장하고 알림을 잘 보내는지") - @Test - void whenFollow_ShouldSaveFollowAndSendNotification() { - Long followerId = 1L; - Long followingId = 1L; - Member follower = new Member(followerId, "email", "12345678", "follower", "image.jpg", SocialLoginType.KAKAO); - Member following = new Member(followingId, "email2", "87654321", "following", "profile.png", SocialLoginType.KAKAO); - FollowCount followerCount = new FollowCount(followerId, 1, 0); - FollowCount followingCount = new FollowCount(followingId, 0, 1); - - when(memberRepository.findById(followerId)).thenReturn(Optional.of(follower)); - when(memberRepository.findById(followingId)).thenReturn(Optional.of(following)); - when(followCountRepository.findById(followerId)).thenReturn(Optional.of(followerCount)); - when(followCountRepository.findById(followingId)).thenReturn(Optional.of(followingCount)); - - followService.follow(followerId, followingId); - - verify(followRepository, times(1)).save(any(Follow.class)); - verify(notificationService, times(1)).sendNotification(any(Notification.class)); - } - - @DisplayName("follow 메서드가 존재하지 않는 사용자에 대한 요청을 보냈을 때 예외가 잘 발생하는지") - @Test - void whenFollowWithInvalidMemberInfo_ShouldThrowException() { - Long nonExistingId = 3L; - - when(memberRepository.findById(nonExistingId)).thenReturn(Optional.empty()); - - assertThrows(BadRequestException.class, () -> followService.follow(1L, nonExistingId)); - } - - @DisplayName("unfollow 메서드가 팔로우를 잘 삭제하는지") - @Test - void whenUnfollow_ShouldDeleteFollow() { - Long followerId = 1L; - Long followingId = 2L; - Member unfollower = new Member(followerId, "email", "12345678", "follower", "image.jpg", SocialLoginType.KAKAO); - Member unfollowing = new Member(followingId, "email2", "87654321", "following", "profile.png", SocialLoginType.KAKAO); - FollowCount followerCount = new FollowCount(followerId, 0, 0); - FollowCount followingCount = new FollowCount(followingId, 0, 0); - - when(memberRepository.findById(followerId)).thenReturn(Optional.of(unfollower)); - when(memberRepository.findById(followingId)).thenReturn(Optional.of(unfollowing)); - when(followCountRepository.findById(followerId)).thenReturn(Optional.of(followerCount)); - when(followCountRepository.findById(followingId)).thenReturn(Optional.of(followingCount)); - - followService.unfollow(followerId, followingId); - - verify(followRepository, times(1)).deleteByFollowerAndFollowing(unfollower, unfollowing); - } - -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/domain/FollowServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/domain/FollowServiceImplTest.java new file mode 100644 index 000000000..c559b48a8 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/follow/domain/FollowServiceImplTest.java @@ -0,0 +1,169 @@ +package kr.co.yigil.follow.domain; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +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.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +public class FollowServiceImplTest { + + @Mock + private FollowReader followReader; + + @Mock + private MemberReader memberReader; + + @Mock + private FollowStore followStore; + + @Mock + private FollowCacheStore followCacheStore; + + @InjectMocks + private FollowServiceImpl followService; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("유효한 파라미터로 follow 메서드가 잘 호출되는지") + @Test + void whenFollow_valid_thenCallsMethod() { + Long followerId = 1L; + Long followingId = 2L; + + when(followReader.isFollowing(anyLong(), anyLong())).thenReturn(false); + + Member mockMember = mock(Member.class); + when(memberReader.getMember(anyLong())).thenReturn(mockMember); + + followService.follow(followerId, followingId); + + verify(followStore, times(1)).store(any(Member.class), any(Member.class)); + verify(followCacheStore, times(1)).incrementFollowersCount(followingId); + verify(followCacheStore, times(1)).incrementFollowingsCount(followerId); + } + + @DisplayName("이미 팔로우 중인 회원이 follow를 호출 시 예외가 잘 발생되는지") + @Test + void whenFollow_alreadyFollowing_thenThrowsException() { + Long followerId = 1L; + Long followingId = 2L; + + when(followReader.isFollowing(anyLong(), anyLong())).thenReturn(true); + + assertThrows( + BadRequestException.class, () -> followService.follow(followerId, followingId)); + + } + @DisplayName("자기 자신을 팔로우하려 할 때 예외가 잘 발생되는지") + @Test + void whenFollow_self_thenThrowsException() { + Long followerId = 1L; + Long followingId = 1L; + + assertThrows( + BadRequestException.class, () -> followService.follow(followerId, followingId)); + } + + @DisplayName("유효한 파라미터로 unfollow 메서드가 잘 호출되는지") + @Test + void whenUnfollow_valid_thenCallsMethod() { + Long unfollowerId = 1L; + Long unfollowingId = 2L; + + when(followReader.isFollowing(anyLong(), anyLong())).thenReturn(true); + + Member mockMember = mock(Member.class); + when(memberReader.getMember(anyLong())).thenReturn(mockMember); + + followService.unfollow(unfollowerId, unfollowingId); + + verify(followStore, times(1)).remove(any(Member.class), any(Member.class)); + verify(followCacheStore, times(1)).decrementFollowersCount(unfollowingId); + verify(followCacheStore, times(1)).decrementFollowingsCount(unfollowerId); + } + + @DisplayName("이미 언팔로우 한 회원이 unfollow를 호출할 경우 예외가 잘 발생되는지") + @Test + void whenUnfollow_notFollowing_thenThrowsException() { + Long unfollowerId = 1L; + Long unfollowingId = 2L; + + when(followReader.isFollowing(anyLong(), anyLong())).thenReturn(false); + + assertThrows( + BadRequestException.class, () -> followService.unfollow(unfollowerId, unfollowingId)); + } + + @DisplayName("자기 자신을 언팔로우하려 할 때 예외가 잘 발생되는지") + @Test + void whenUnfollow_self_thenThrowsException() { + Long unfollowerId = 1L; + Long unfollowingId = 1L; + + assertThrows( + BadRequestException.class, () -> followService.unfollow(unfollowerId, unfollowingId)); + } + + + @DisplayName("getFollowerList 를 호출했을 때 팔로워 리스트 조회가 잘 되는지 확인") + @Test + void WhenGetFollowerList_ThenShouldReturnFollowerResponse() { + Long memberId = 1L; + PageRequest pageable = PageRequest.of(0, 10); + + // 필수 멤버 필드: id, nickname, profileImageUrl + Member mockMember = new Member(memberId, null, null, "nickname", "profileImageUrl", null); + Follow mockFollow = mock(Follow.class); + + Slice mockFollowList = new SliceImpl<>(List.of(mockFollow)); + + when(followReader.getFollowerSlice(anyLong(), any())).thenReturn(mockFollowList); + when(mockFollow.getFollower()).thenReturn(mockMember); + + var result = followService.getFollowerList(memberId, pageable); + + assertThat(result).isNotNull().isInstanceOf(FollowInfo.FollowersResponse.class); + } + + @DisplayName("getFollowingList 를 호출했을 때 팔로잉 리스트 조회가 잘 되는지 확인") + @Test + void WhenGetFollowingList_ThenShouldReturnFollowingResponse() { + Long memberId = 1L; + PageRequest pageable = PageRequest.of(0, 10); + + // 필수 멤버 필드: id, nickname, profileImageUrl + Member mockMember = new Member(memberId, null, null, "nickname", "profileImageUrl", null); + Follow mockFollow = mock(Follow.class); + + Slice mockFollowList = new SliceImpl<>(List.of(mockFollow)); + + when(followReader.getFollowingSlice(anyLong(), any())).thenReturn(mockFollowList); + when(mockFollow.getFollowing()).thenReturn(mockMember); + + var result = followService.getFollowingList(memberId, pageable); + + assertThat(result).isNotNull().isInstanceOf(FollowInfo.FollowingsResponse.class); + } + +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/domain/FollowTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/domain/FollowTest.java index d38ac4da1..14090b9ca 100644 --- a/backend/yigil-api/src/test/java/kr/co/yigil/follow/domain/FollowTest.java +++ b/backend/yigil-api/src/test/java/kr/co/yigil/follow/domain/FollowTest.java @@ -3,8 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/domain/repository/FollowCountRedisRepositoryTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/domain/repository/FollowCountRedisRepositoryTest.java deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/infrastructure/FollowReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/infrastructure/FollowReaderImplTest.java new file mode 100644 index 000000000..706dff458 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/follow/infrastructure/FollowReaderImplTest.java @@ -0,0 +1,107 @@ +package kr.co.yigil.follow.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import kr.co.yigil.follow.FollowCountDto; +import kr.co.yigil.follow.domain.Follow; +import kr.co.yigil.follow.domain.FollowCount; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +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.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +public class FollowReaderImplTest { + + @Mock + private FollowRepository followRepository; + + @Mock + private MemberReader memberReader; + + @InjectMocks + private FollowReaderImpl followReader; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("getFollowCount 메서드가 올바른 FollowCount를 반환하는지") + @Test + void whenGetFollowCount_thenReturnsCorrectFollowCount() { + Long memberId = 1L; + FollowCountDto followCountDto = new FollowCountDto(10L, 20L); + FollowCount expectedFollowCount = new FollowCount(memberId, 10, 20); + + when(followRepository.getFollowCounts(memberId)).thenReturn(followCountDto); + + FollowCount actualFollowCount = followReader.getFollowCount(memberId); + + assertEquals(expectedFollowCount, actualFollowCount); + } + + @DisplayName("isFollowing 메서드가 올바른 결과를 반환하는지") + @Test + void whenIsFollowing_thenReturnsCorrectResult() { + Long followerId = 1L; + Long followingId = 2L; + + when(followRepository.existsByFollowerIdAndFollowingId(followerId, followingId)).thenReturn(true); + + boolean isFollowing = followReader.isFollowing(followerId, followingId); + + assertTrue(isFollowing); + } + + @DisplayName("getFollowerSlice 메서드가 올바른 Slice를 반환하는지") + @Test + void whenGetFollowerSlice_thenReturnsCorrectSlice() { + Long memberId = 1L; + Long anotherMemberId = 2L; + PageRequest pageRequest = PageRequest.of(0, 10); + + Member followerMember = new Member(anotherMemberId, null, null, null, null, null); + Member followingMember = new Member(memberId, null, null, null, null, null); + + Follow mockFollow = new Follow(followerMember, followingMember); + List follows = List.of(mockFollow); + Slice expectedSlice = new SliceImpl<>(follows, pageRequest, true); + + when(followRepository.findAllByFollowingId(anyLong(), any(Pageable.class))).thenReturn(expectedSlice); + + Slice actualSlice = followReader.getFollowerSlice(memberId, pageRequest); + + assertEquals(expectedSlice, actualSlice); + } + + @DisplayName("getFollowingSlice 메서드가 올바른 Slice를 반환하는지") + @Test + void whenGetFollowingSlice_thenReturnsCorrectSlice() { + Long memberId = 1L; + Member member = new Member("kiit0901@gmail.com", "123456", "stone", "profile.jpg", "kakao"); + List follows = new ArrayList<>(); + Slice expectedSlice = new SliceImpl<>(follows); + + when(memberReader.getMember(memberId)).thenReturn(member); + when(followRepository.findAllByFollowerId(anyLong(), any(Pageable.class))).thenReturn(expectedSlice); + + Slice actualSlice = followReader.getFollowingSlice(memberId, Pageable.unpaged()); + + assertEquals(expectedSlice, actualSlice); + } + +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/infrastructure/FollowStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/infrastructure/FollowStoreImplTest.java new file mode 100644 index 000000000..0e238817e --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/follow/infrastructure/FollowStoreImplTest.java @@ -0,0 +1,47 @@ +package kr.co.yigil.follow.infrastructure; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import kr.co.yigil.follow.domain.Follow; +import kr.co.yigil.member.Member; +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; + +public class FollowStoreImplTest { + + @Mock + private FollowRepository followRepository; + + @InjectMocks + private FollowStoreImpl followStore; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + @DisplayName("팔로우가 요청되었을 때 FollowRepository의 save 메서드가 호출되는지") + @Test + void whenStored_thenSaveIsCalled() { + Member follower = new Member("kiit0901@gmail.com", "123456", "stone", "profile.jpg", "kakao"); + Member following = new Member("kiit09sdf01@gmail.com", "123456", "stone", "profile.jpg", "kakao"); + + followStore.store(follower, following); + + verify(followRepository, times(1)).save(new Follow(follower, following)); + } + @DisplayName("언팔로우가 요청되었을 때 FollowRepository의 deleteByFollowerAndFollowing 메서드가 호출되는지") + @Test + void whenRemoved_thenDeleteByFollowerAndFollowingIsCalled() { + Member unfollower = new Member("kiit0901@gmail.com", "123456", "stone", "profile.jpg", "kakao"); + Member unfollowing = new Member("kiit09sdf01@gmail.com", "123456", "stone", "profile.jpg", "kakao"); + + followStore.remove(unfollower, unfollowing); + + verify(followRepository, times(1)).deleteByFollowerAndFollowing(unfollower, unfollowing); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/interfaces/controller/FollowApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/interfaces/controller/FollowApiControllerTest.java new file mode 100644 index 000000000..8d06be3e9 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/follow/interfaces/controller/FollowApiControllerTest.java @@ -0,0 +1,287 @@ +package kr.co.yigil.follow.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +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.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import kr.co.yigil.follow.application.FollowFacade; +import kr.co.yigil.follow.domain.FollowInfo; +import kr.co.yigil.follow.interfaces.dto.FollowDto.FollowerInfo; +import kr.co.yigil.follow.interfaces.dto.FollowDto.FollowersResponse; +import kr.co.yigil.follow.interfaces.dto.FollowDto.FollowingInfo; +import kr.co.yigil.follow.interfaces.dto.FollowDto.FollowingsResponse; +import kr.co.yigil.follow.interfaces.dto.FollowDtoMapper; +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.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.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(FollowApiController.class) +@AutoConfigureRestDocs +public class FollowApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private FollowFacade followFacade; + + @MockBean + private FollowDtoMapper followDtoMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation)).build(); + } + + @DisplayName("팔로우가 요청되었을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenFollow_thenReturns200AndFollowResponse() throws Exception { + Long memberId = 2L; + + mockMvc.perform(post("/api/v1/follows/follow/{member_id}", memberId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().json("{\"message\":\"팔로우 성공\"}")) + .andDo(document("follows/follow", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("member_id").description("팔로우할 회원 아이디") + ), + responseFields( + fieldWithPath("message").description("응답 메시지") + ) + )); + + verify(followFacade).follow(anyLong(), anyLong()); + } + + @DisplayName("언팔로우가 요청되었을 때 200 응답과 Response가 잘 반환되는지") + @Test + void whenUnfollow_thenReturns200AndUnfollowResponse() throws Exception { + Long memberId = 2L; + + mockMvc.perform(post("/api/v1/follows/unfollow/{member_id}", memberId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().json("{\"message\":\"언팔로우 성공\"}")) + .andDo(document("follows/unfollow", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("member_id").description("언팔로우할 회원 아이디") + ), + responseFields( + fieldWithPath("message").description("응답 메시지") + ) + )); + + verify(followFacade).unfollow(anyLong(), anyLong()); + } + + @DisplayName("내 팔로우 목록 조회가 잘 되는지") + @Test + void WhenGetMyFollowerList_ThenShouldReturnOk() throws Exception { + + FollowersResponse response = FollowersResponse.builder() + .content(List.of(FollowerInfo.builder() + .memberId(1L) + .nickname("test user") + .profileImageUrl("https://cdn.yigil.co.kr/images/profile.jpg") + .following(true) + .build()) + ) + .hasNext(false) + .build(); + + when(followFacade.getFollowerList(anyLong(), any(PageRequest.class))).thenReturn( + mock(FollowInfo.FollowersResponse.class)); + when(followDtoMapper.of(any(FollowInfo.FollowersResponse.class))).thenReturn(response); + + mockMvc.perform(get("/api/v1/follows/followers") + .param("page", "1") + .param("size", "5") + .param("sortBy", "id") + .param("sortOrder", "asc") + ) + .andExpect(status().isOk()) + .andDo(document( + "follows/get-my-follower-list", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("현재 페이지").optional(), + parameterWithName("size").description("페이지 크기").optional(), + parameterWithName("sortBy").description("정렬 옵션").optional(), + parameterWithName("sortOrder").description("정렬 순서").optional() + ), + responseFields( + fieldWithPath("content[].member_id").description("회원 ID"), + fieldWithPath("content[].nickname").description("닉네임"), + fieldWithPath("content[].profile_image_url").description("프로필 이미지 URL"), + fieldWithPath("content[].following").description("팔로우 여부"), + fieldWithPath("has_next").description("다음 페이지 존재 여부") + ) + + )); + verify(followFacade).getFollowerList(anyLong(), any(PageRequest.class)); + } + + @DisplayName("팔로잉 목록 조회가 잘 되는지") + @Test + void WhenGetMyFollowingList_ThenShouldReturnOk() throws Exception { + + FollowingsResponse response = FollowingsResponse.builder() + .content(List.of(FollowingInfo.builder() + .memberId(1L) + .nickname("test user") + .profileImageUrl("https://cdn.yigil.co.kr/images/profile.jpg") + .build()) + ) + .hasNext(false) + .build(); + + when(followFacade.getFollowingList(anyLong(), any(PageRequest.class))).thenReturn( + mock(FollowInfo.FollowingsResponse.class)); + when(followDtoMapper.of(any(FollowInfo.FollowingsResponse.class))).thenReturn(response); + mockMvc.perform(get("/api/v1/follows/followings") + .param("page", "1") + .param("size", "5") + .param("sortBy", "id") + .param("sortOrder", "asc") + ) + .andExpect(status().isOk()) + .andDo(document( + "follows/get-my-following-list", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("현재 페이지").optional(), + parameterWithName("size").description("페이지 크기").optional(), + parameterWithName("sortBy").description("정렬 옵션").optional(), + parameterWithName("sortOrder").description("정렬 순서").optional() + ), + responseFields( + fieldWithPath("content[].member_id").description("회원 ID"), + fieldWithPath("content[].nickname").description("닉네임"), + fieldWithPath("content[].profile_image_url").description("프로필 이미지 URL"), + fieldWithPath("has_next").description("다음 페이지 존재 여부") + ) + )); + verify(followFacade).getFollowingList(anyLong(), any(PageRequest.class)); + } + + @DisplayName("회원을 팔로우하는 목록이 잘 조회되는지") + @Test + void WhenGetMemberFollowerList_ThenReturnOk() throws Exception { + + FollowersResponse response = FollowersResponse.builder() + .content(List.of(FollowerInfo.builder() + .memberId(1L) + .nickname("test user") + .profileImageUrl("https://cdn.yigil.co.kr/images/profile.jpg") + .following(true) + .build()) + ) + .hasNext(false) + .build(); + + when(followFacade.getFollowerList(anyLong(), any(PageRequest.class))).thenReturn( + mock(FollowInfo.FollowersResponse.class)); + when(followDtoMapper.of(any(FollowInfo.FollowersResponse.class))).thenReturn(response); + + mockMvc.perform(get("/api/v1/follows/{memberId}/followers", 1L) + .param("page", "1") + .param("size", "5") + .param("sortBy", "id") + .param("sortOrder", "asc") + ) + .andExpect(status().isOk()) + .andDo(document( + "follows/get-member-follower-list", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + fieldWithPath("content[].member_id").description("회원 ID"), + fieldWithPath("content[].nickname").description("닉네임"), + fieldWithPath("content[].profile_image_url").description("프로필 이미지 URL"), + fieldWithPath("content[].following").description("팔로우 여부"), + fieldWithPath("has_next").description("다음 페이지 존재 여부") + ) + )); + + verify(followFacade).getFollowerList(anyLong(), any(PageRequest.class)); + + } + + @DisplayName("회원이 팔로우하는 목록이 잘 조회되는지") + @Test + void WhenGetMemberFollowingList_ThenShouldReturnOk() throws Exception { + + FollowingsResponse response = FollowingsResponse.builder() + .content(List.of(FollowingInfo.builder() + .memberId(1L) + .nickname("test user") + .profileImageUrl("https://cdn.yigil.co.kr/images/images/profile.jpg") + .build()) + ) + .hasNext(false) + .build(); + + when(followFacade.getFollowingList(anyLong(), any(PageRequest.class))).thenReturn( + mock(FollowInfo.FollowingsResponse.class)); + when(followDtoMapper.of(any(FollowInfo.FollowingsResponse.class))).thenReturn(response); + + mockMvc.perform(get("/api/v1/follows/{memberId}/followings", 1L) + .param("page", "1") + .param("size", "5") + .param("sortBy", "id") + .param("sortOrder", "asc") + ) + .andExpect(status().isOk()) + .andDo(document( + "follows/get-member-following-list", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + fieldWithPath("content[].member_id").description("회원 ID"), + fieldWithPath("content[].nickname").description("닉네임"), + fieldWithPath("content[].profile_image_url").description("프로필 이미지 URL"), + fieldWithPath("has_next").description("다음 페이지 존재 여부") + ) + )); + + verify(followFacade).getFollowingList(anyLong(), any(PageRequest.class)); + } + +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/follow/presentation/FollowControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/follow/presentation/FollowControllerTest.java deleted file mode 100644 index 74eeee100..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/follow/presentation/FollowControllerTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package kr.co.yigil.follow.presentation; - -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import kr.co.yigil.follow.application.FollowService; -import kr.co.yigil.follow.dto.response.FollowResponse; -import kr.co.yigil.follow.dto.response.UnfollowResponse; -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.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@ExtendWith(SpringExtension.class) -@WebMvcTest(FollowController.class) -public class FollowControllerTest { - - private MockMvc mockMvc; - - @MockBean - private FollowService followService; - - @InjectMocks - private FollowController followController; - - @BeforeEach - void setUp(WebApplicationContext webApplicationContext) { - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } - - @DisplayName("팔로우가 요청되었을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenFollow_thenReturns200AndFollowResponse() throws Exception { - Long accessorId = 1L; - Long memberId = 2L; - FollowResponse mockResponse = new FollowResponse(); - - given(followService.follow(accessorId, memberId)).willReturn(mockResponse); - - mockMvc.perform(post("/api/v1/follow/" + memberId) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @DisplayName("언팔로우가 요청되었을 때 200 응답과 Response가 잘 반환되는지") - @Test - void whenUnfollow_thenReturns200AndUnfollowResponse() throws Exception { - Long accessorId = 1L; - Long memberId = 2L; - UnfollowResponse mockResponse = new UnfollowResponse(); - - given(followService.unfollow(accessorId, memberId)).willReturn(mockResponse); - - mockMvc.perform(post("/api/v1/unfollow/" + memberId) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/global/config/JasyptConfigTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/global/config/JasyptConfigTest.java index 5a6ca5c5b..4afe91f04 100644 --- a/backend/yigil-api/src/test/java/kr/co/yigil/global/config/JasyptConfigTest.java +++ b/backend/yigil-api/src/test/java/kr/co/yigil/global/config/JasyptConfigTest.java @@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import kr.co.yigil.login.application.strategy.KakaoLoginStrategy; import org.jasypt.encryption.StringEncryptor; import org.jasypt.encryption.pbe.PooledPBEStringEncryptor; import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig; diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/login/application/LoginFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/login/application/LoginFacadeTest.java new file mode 100644 index 000000000..1ffc78934 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/login/application/LoginFacadeTest.java @@ -0,0 +1,49 @@ +package kr.co.yigil.login.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.login.domain.LoginCommand; +import kr.co.yigil.login.domain.LoginStrategyManager; +import kr.co.yigil.login.infrastructure.LoginStrategy; +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; + +class LoginFacadeTest { + @Mock + private LoginStrategyManager loginStrategyManager; + + @InjectMocks + private LoginFacade loginFacade; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("executeLoginStrategy 메서드가 LoginStrategyManager의 메서드를 잘 호출하는지") + @Test + void executeLoginStrategy() { + LoginCommand.LoginRequest loginCommand = mock(LoginCommand.LoginRequest.class); + when(loginCommand.getProvider()).thenReturn("provider"); + LoginStrategy loginStrategy = mock(LoginStrategy.class); + when(loginStrategyManager.getLoginStrategy(loginCommand.getProvider())).thenReturn(loginStrategy); + when(loginStrategy.processLogin(loginCommand, "accessToken")).thenReturn(1L); + + loginFacade.executeLoginStrategy(loginCommand, "accessToken"); + + verify(loginStrategyManager, times(1)).getLoginStrategy(loginCommand.getProvider()); + verify(loginStrategy, times(1)).processLogin(loginCommand, "accessToken"); + assertThat(loginFacade.executeLoginStrategy(loginCommand, "accessToken")).isEqualTo(1L); + } + + +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/login/application/strategy/GoogleLoginStrategyTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/login/infrastructure/GoogleLoginStrategyTest.java similarity index 61% rename from backend/yigil-api/src/test/java/kr/co/yigil/login/application/strategy/GoogleLoginStrategyTest.java rename to backend/yigil-api/src/test/java/kr/co/yigil/login/infrastructure/GoogleLoginStrategyTest.java index 24e9e5345..e089db873 100644 --- a/backend/yigil-api/src/test/java/kr/co/yigil/login/application/strategy/GoogleLoginStrategyTest.java +++ b/backend/yigil-api/src/test/java/kr/co/yigil/login/infrastructure/GoogleLoginStrategyTest.java @@ -1,4 +1,4 @@ -package kr.co.yigil.login.application.strategy; +package kr.co.yigil.login.infrastructure; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; @@ -9,17 +9,15 @@ import static org.mockito.Mockito.when; import static org.springframework.http.HttpMethod.GET; -import jakarta.servlet.http.HttpSession; import java.util.Optional; import kr.co.yigil.global.exception.ExceptionCode; import kr.co.yigil.global.exception.InvalidTokenException; -import kr.co.yigil.login.dto.request.LoginRequest; -import kr.co.yigil.login.dto.response.GoogleTokenInfoResponse; -import kr.co.yigil.login.dto.response.KakaoTokenInfoResponse; -import kr.co.yigil.login.dto.response.LoginResponse; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.member.domain.repository.MemberRepository; +import kr.co.yigil.login.domain.LoginCommand; +import kr.co.yigil.login.interfaces.dto.response.GoogleTokenInfoResponse; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.member.domain.MemberStore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -37,7 +35,10 @@ public class GoogleLoginStrategyTest { @MockBean - private MemberRepository memberRepository; + private MemberStore memberStore; + + @MockBean + private MemberReader memberReader; @MockBean private RestTemplate restTemplate; @@ -53,8 +54,8 @@ void setUp() { @DisplayName("토큰이 유효하고 멤버가 존재하면 로그인이 잘 되는지") @Test void whenTokenIsValid_andMemberExists_thenLoginSuccessful() { - GoogleTokenInfoResponse mockResponse = new GoogleTokenInfoResponse(); - mockResponse.setUserId(12345678L); + GoogleTokenInfoResponse mockResponse = new GoogleTokenInfoResponse("email@example.com", 50000); + String accessToken = "mockAccessToken"; when(restTemplate.exchange( @@ -65,18 +66,19 @@ void whenTokenIsValid_andMemberExists_thenLoginSuccessful() { eq(accessToken) )).thenReturn(ResponseEntity.ok(mockResponse)); - Member mockMember = new Member("email@example.com", "12345678", "user", "image_url", "google"); + Long memberId = 1L; + Member mockMember = new Member(memberId,"email@example.com", "12345678", "user", "image_url", SocialLoginType.GOOGLE); - when(memberRepository.findMemberBySocialLoginIdAndSocialLoginType("12345678", SocialLoginType.GOOGLE)).thenReturn( + when(memberReader.findMemberByEmailAndSocialLoginType("email@example.com", SocialLoginType.GOOGLE)).thenReturn( Optional.of(mockMember)); - HttpSession mockSession = mock(HttpSession.class); - - LoginRequest loginRequest = new LoginRequest(12345678L, "user", "image_url", "email@example.com"); + LoginCommand.LoginRequest loginCommand = mock(LoginCommand.LoginRequest.class); + when(loginCommand.getEmail()).thenReturn("email@example.com"); + when(loginCommand.getId()).thenReturn("12345678"); - LoginResponse response = googleLoginStrategy.login(loginRequest, accessToken, mockSession); + Long response = googleLoginStrategy.processLogin(loginCommand, accessToken); - assertThat(response.getMessage()).isEqualTo("로그인 성공"); + assertThat(response).isEqualTo(memberId); } @DisplayName("토큰이 유효하지 않은 경우 예외가 잘 발생하는지") @@ -92,10 +94,9 @@ void whenTokenIsInvalid_thenThrowsException() { eq(accessToken) )).thenThrow(new InvalidTokenException(ExceptionCode.INVALID_ACCESS_TOKEN)); - LoginRequest loginRequest = new LoginRequest(12345678L, "user", "image_url", "email@example.com"); - HttpSession mockSession = mock(HttpSession.class); + LoginCommand.LoginRequest loginCommand = mock(LoginCommand.LoginRequest.class); - Throwable thrown = catchThrowable(() -> googleLoginStrategy.login(loginRequest, accessToken, mockSession)); + Throwable thrown = catchThrowable(() -> googleLoginStrategy.processLogin(loginCommand, accessToken)); assertThat(thrown).isInstanceOf(InvalidTokenException.class); } @@ -104,7 +105,7 @@ void whenTokenIsInvalid_thenThrowsException() { @Test void whenMemberIsNew_thenRegisterNewMember() { GoogleTokenInfoResponse mockResponse = new GoogleTokenInfoResponse(); - mockResponse.setUserId(12345678L); + mockResponse.setEmail("test@test.com"); String accessToken = "mockAccessToken"; when(restTemplate.exchange( @@ -115,15 +116,19 @@ void whenMemberIsNew_thenRegisterNewMember() { eq(accessToken) )).thenReturn(ResponseEntity.ok(mockResponse)); - when(memberRepository.findMemberBySocialLoginIdAndSocialLoginType("12345678", SocialLoginType.GOOGLE)).thenReturn(Optional.empty()); - when(memberRepository.save(any(Member.class))).thenAnswer(invocation -> invocation.getArgument(0)); + LoginCommand.LoginRequest loginCommand = mock(LoginCommand.LoginRequest.class); + when(loginCommand.getId()).thenReturn("12345678"); + when(loginCommand.getEmail()).thenReturn("test@test.com"); - HttpSession mockSession = mock(HttpSession.class); + when(memberReader.findMemberByEmailAndSocialLoginType("test@test.com", SocialLoginType.GOOGLE)).thenReturn(Optional.empty()); + Long memberId = 1L; + Member mockMember = new Member(memberId,"email@example.com", "12345678", "user", "image_url", SocialLoginType.GOOGLE); + when(loginCommand.toEntity(anyString())).thenReturn(mockMember); + when(memberStore.save(any(Member.class))).thenReturn(mockMember); - LoginRequest loginRequest = new LoginRequest(12345678L, "newUser", "new_image_url", "new_email@example.com"); - LoginResponse response = googleLoginStrategy.login(loginRequest, accessToken, mockSession); + Long response = googleLoginStrategy.processLogin(loginCommand, accessToken); - assertThat(response.getMessage()).isEqualTo("로그인 성공"); + assertThat(response).isEqualTo(memberId); } } diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/login/application/strategy/KakaoLoginStrategyTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/login/infrastructure/KakaoLoginStrategyTest.java similarity index 61% rename from backend/yigil-api/src/test/java/kr/co/yigil/login/application/strategy/KakaoLoginStrategyTest.java rename to backend/yigil-api/src/test/java/kr/co/yigil/login/infrastructure/KakaoLoginStrategyTest.java index 4333c30b5..8cd101d2f 100644 --- a/backend/yigil-api/src/test/java/kr/co/yigil/login/application/strategy/KakaoLoginStrategyTest.java +++ b/backend/yigil-api/src/test/java/kr/co/yigil/login/infrastructure/KakaoLoginStrategyTest.java @@ -1,4 +1,4 @@ -package kr.co.yigil.login.application.strategy; +package kr.co.yigil.login.infrastructure; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; @@ -9,17 +9,15 @@ import static org.mockito.Mockito.when; import static org.springframework.http.HttpMethod.GET; - -import jakarta.servlet.http.HttpSession; import java.util.Optional; import kr.co.yigil.global.exception.ExceptionCode; import kr.co.yigil.global.exception.InvalidTokenException; -import kr.co.yigil.login.dto.request.LoginRequest; -import kr.co.yigil.login.dto.response.KakaoTokenInfoResponse; -import kr.co.yigil.login.dto.response.LoginResponse; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.member.domain.repository.MemberRepository; +import kr.co.yigil.login.domain.LoginCommand; +import kr.co.yigil.login.interfaces.dto.response.KakaoTokenInfoResponse; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.member.domain.MemberStore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -37,7 +35,10 @@ public class KakaoLoginStrategyTest { @MockBean - private MemberRepository memberRepository; + private MemberStore memberStore; + + @MockBean + private MemberReader memberReader; @MockBean private RestTemplate restTemplate; @@ -62,17 +63,17 @@ void whenTokenIsValid_andMemberExists_thenLoginSuccessful() { eq(KakaoTokenInfoResponse.class)) ).thenReturn(ResponseEntity.ok(mockResponse)); - Member mockMember = new Member("email@example.com", "12345678", "user", "image_url", "kakao"); - - when(memberRepository.findMemberBySocialLoginIdAndSocialLoginType("12345678", SocialLoginType.KAKAO)).thenReturn(Optional.of(mockMember)); + Long memberId = 1L; + Member mockMember = new Member(memberId,"email@example.com", "12345678", "user", "image_url", SocialLoginType.KAKAO); - HttpSession mockSession = mock(HttpSession.class); + when(memberReader.findMemberBySocialLoginIdAndSocialLoginType("12345678", SocialLoginType.KAKAO)).thenReturn(Optional.of(mockMember)); - LoginRequest loginRequest = new LoginRequest(12345678L, "user", "image_url", "email@example.com"); + LoginCommand.LoginRequest loginCommand = mock(LoginCommand.LoginRequest.class); + when(loginCommand.getId()).thenReturn("12345678"); - LoginResponse response = kakaoLoginStrategy.login(loginRequest, "mockAccessToken", mockSession); + Long response = kakaoLoginStrategy.processLogin(loginCommand, "mockAccessToken"); - assertThat(response.getMessage()).isEqualTo("로그인 성공"); + assertThat(response).isEqualTo(memberId); } @DisplayName("토큰이 유효하지 않은 경우 예외가 잘 발생하는지") @@ -81,10 +82,9 @@ void whenTokenIsInvalid_thenThrowsException() { when(restTemplate.exchange(anyString(), eq(GET), any(HttpEntity.class), eq(KakaoTokenInfoResponse.class))) .thenThrow(new InvalidTokenException(ExceptionCode.INVALID_ACCESS_TOKEN)); - LoginRequest loginRequest = new LoginRequest(12345678L, "user", "image_url", "email@example.com"); - HttpSession mockSession = mock(HttpSession.class); + LoginCommand.LoginRequest loginCommand = mock(LoginCommand.LoginRequest.class); - Throwable thrown = catchThrowable(() -> kakaoLoginStrategy.login(loginRequest, "invalidAccessToken", mockSession)); + Throwable thrown = catchThrowable(() -> kakaoLoginStrategy.processLogin(loginCommand, "invalidAccessToken")); assertThat(thrown).isInstanceOf(InvalidTokenException.class); } @@ -97,14 +97,17 @@ void whenMemberIsNew_thenRegisterNewMember() { when(restTemplate.exchange(anyString(), eq(GET), any(HttpEntity.class), eq(KakaoTokenInfoResponse.class))) .thenReturn(ResponseEntity.ok(mockResponse)); - when(memberRepository.findMemberBySocialLoginIdAndSocialLoginType("12345678", SocialLoginType.KAKAO)).thenReturn(Optional.empty()); - when(memberRepository.save(any(Member.class))).thenAnswer(invocation -> invocation.getArgument(0)); + LoginCommand.LoginRequest loginCommand = mock(LoginCommand.LoginRequest.class); + when(loginCommand.getId()).thenReturn("12345678"); - HttpSession mockSession = mock(HttpSession.class); + Long memberId = 1L; + Member mockMember = new Member(memberId,"email@example.com", "12345678", "user", "image_url", SocialLoginType.KAKAO); + when(memberReader.findMemberBySocialLoginIdAndSocialLoginType("12345678", SocialLoginType.KAKAO)).thenReturn(Optional.empty()); + when(loginCommand.toEntity(anyString())).thenReturn(mockMember); + when(memberStore.save(any(Member.class))).thenReturn(mockMember); - LoginRequest loginRequest = new LoginRequest(12345678L, "newUser", "new_image_url", "new_email@example.com"); - LoginResponse response = kakaoLoginStrategy.login(loginRequest, "mockAccessToken", mockSession); + Long response = kakaoLoginStrategy.processLogin(loginCommand, "mockAccessToken"); - assertThat(response.getMessage()).isEqualTo("로그인 성공"); + assertThat(response).isEqualTo(memberId); } } \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/login/application/LoginArgumentResolverTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/login/infrastructure/LoginArgumentResolverTest.java similarity index 96% rename from backend/yigil-api/src/test/java/kr/co/yigil/login/application/LoginArgumentResolverTest.java rename to backend/yigil-api/src/test/java/kr/co/yigil/login/infrastructure/LoginArgumentResolverTest.java index a6196739e..42b7a228e 100644 --- a/backend/yigil-api/src/test/java/kr/co/yigil/login/application/LoginArgumentResolverTest.java +++ b/backend/yigil-api/src/test/java/kr/co/yigil/login/infrastructure/LoginArgumentResolverTest.java @@ -1,4 +1,4 @@ -package kr.co.yigil.login.application; +package kr.co.yigil.login.infrastructure; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -8,6 +8,7 @@ import jakarta.servlet.http.HttpSession; import kr.co.yigil.auth.Auth; import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.login.infrastructure.LoginArgumentResolver; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/login/interfaces/controller/LoginApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/login/interfaces/controller/LoginApiControllerTest.java new file mode 100644 index 000000000..31fdab9ee --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/login/interfaces/controller/LoginApiControllerTest.java @@ -0,0 +1,115 @@ +package kr.co.yigil.login.interfaces.controller; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +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.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import kr.co.yigil.login.application.LoginFacade; +import kr.co.yigil.login.interfaces.dto.mapper.LoginMapper; +import kr.co.yigil.login.interfaces.dto.request.LoginRequest; +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.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.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(LoginApiController.class) +@AutoConfigureRestDocs +public class LoginApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private LoginFacade loginFacade; + + @MockBean + private LoginMapper loginMapper; + + @InjectMocks + private LoginApiController loginApiController; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation)).build(); + } + + @DisplayName("카카오 로그인이 요청 되었을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenLogin_thenReturns200AndLoginResponse() throws Exception { + MockHttpSession session = new MockHttpSession(); + + mockMvc.perform(post("/api/v1/login") + .header("Authorization", "Bearer mockAccessToken") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"id\":123, \"nickname\":\"TestUser\", \"profileImageUrl\":\"test.jpg\", \"email\":\"test@example.com\", \"provider\":\"kakao\"}") + .session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message", is("로그인 성공"))) + .andDo(document("login/login", + requestHeaders( + headerWithName("Authorization").description("인증 토큰") + ), + requestFields( + fieldWithPath("id").description("사용자 ID"), + fieldWithPath("nickname").description("사용자 닉네임"), + fieldWithPath("profileImageUrl").description("사용자 프로필 이미지 URL"), + fieldWithPath("email").description("사용자 이메일"), + fieldWithPath("provider").description("인증 제공자") + ), + responseFields( + fieldWithPath("message").description("로그인 성공 메시지") + ) + )); + + verify(loginMapper).toCommandLoginRequest(any(LoginRequest.class)); + verify(loginFacade).executeLoginStrategy(any(), any()); + assertThat(session.getAttribute("memberId")).isNotNull(); + } + + @DisplayName("로그아웃 요청이 들어왔을 때 200 응답과 response가 잘 반환되는지") + @Test + void whenLogout_thenReturns200AndLogoutResponse() throws Exception { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("memberId", 1L); + + mockMvc.perform(get("/api/v1/logout").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message", is("로그아웃 성공"))) + .andDo(document("login/logout", + responseFields( + fieldWithPath("message").description("로그아웃 성공 메시지") + ) + )); + + assertThat(session.isInvalid()).isTrue(); + } + + +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/login/presentation/LoginControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/login/presentation/LoginControllerTest.java deleted file mode 100644 index 72464187d..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/login/presentation/LoginControllerTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package kr.co.yigil.login.presentation; - -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import kr.co.yigil.login.application.LoginStrategyManager; -import kr.co.yigil.login.application.strategy.LoginStrategy; -import kr.co.yigil.login.dto.request.LoginRequest; -import kr.co.yigil.login.dto.response.LoginResponse; -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.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@ExtendWith(SpringExtension.class) -@WebMvcTest(LoginController.class) -public class LoginControllerTest { - - private MockMvc mockMvc; - - @MockBean - private LoginStrategyManager loginStrategyManager; - - @Mock - private LoginStrategy mockStrategy; - - @InjectMocks - private LoginController loginController; - - @BeforeEach - void setUp(WebApplicationContext webApplicationContext) { - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } - - @DisplayName("로그인이 되었을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenLogin_thenReturns200AndLoginResponse() throws Exception { - String provider = "kakao"; - LoginRequest loginRequest = new LoginRequest(); - LoginResponse mockResponse = new LoginResponse(); - - given(loginStrategyManager.getLoginStrategy(provider)).willReturn(mockStrategy); - given(mockStrategy.login(loginRequest, "mockAccessToken", null)).willReturn(mockResponse); - - mockMvc.perform(post("/api/v1/login/" + provider) - .header("Authorization", "Bearer mockAccessToken") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"id\":123, \"nickname\":\"TestUser\", \"profileImageUrl\":\"test.jpg\", \"email\":\"test@example.com\"}")) - .andExpect(status().isOk()); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/member/application/MemberFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/member/application/MemberFacadeTest.java new file mode 100644 index 000000000..d6724d72f --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/member/application/MemberFacadeTest.java @@ -0,0 +1,122 @@ +package kr.co.yigil.member.application; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +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 kr.co.yigil.follow.domain.FollowCount; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.domain.MemberCommand; +import kr.co.yigil.member.domain.MemberInfo; +import kr.co.yigil.member.domain.MemberService; +import kr.co.yigil.region.domain.Region; + +@ExtendWith(MockitoExtension.class) +class MemberFacadeTest { + + @InjectMocks + private MemberFacade memberFacade; + + @Mock + private MemberService memberService; + + @DisplayName("getMemberInfo 메서드가 유효한 요청이 들어왔을 때 MemberInfo의 Main 객체를 잘 반환하는지") + @Test + void WhenGetMemberInfo_ThenShouldReturnMemberInfoMain() { + // Given + Long memberId = 1L; + String email = "member1@email.com"; + String socialLoginId = "12345"; + String nickname = "member1"; + String profileImageUrl = "http://profile.com/member1"; + + List regions = List.of( + new Region(), + new Region() + ); + int followingCount = 10; + int followerCount = 20; + + Member mockMember = new Member( + memberId, + email, + socialLoginId, + nickname, + profileImageUrl, + SocialLoginType.KAKAO + ); + FollowCount mockFollowCount = new FollowCount( + 1L, + followingCount, + followerCount + ); + + MemberInfo.Main mockMemberInfoMain = new MemberInfo.Main(mockMember, mockFollowCount); + + when(memberService.retrieveMemberInfo(memberId)).thenReturn(mockMemberInfoMain); + + // When + var result = memberFacade.getMemberInfo(memberId); + + // Then + assertThat(result).isNotNull() + .isInstanceOf(MemberInfo.Main.class) + .usingRecursiveComparison().isEqualTo(mockMemberInfoMain); + } + + @DisplayName("updateMemberInfo 메서드가 유효한 요청이 들어왔을 때 MemberService의 updateMemberInfo 메서드를 잘 호출하는지") + @Test + void WhenUpdateMemberInfo_ThenShouldValidResponse() { + + MemberCommand.MemberUpdateRequest command = mock(MemberCommand.MemberUpdateRequest.class); + Long memberId = 1L; + + var result = memberFacade.updateMemberInfo(memberId, command); + verify(memberService).updateMemberInfo(memberId, command); + + assertThat(result).isNotNull() + .isInstanceOf(MemberInfo.MemberUpdateResponse.class) + .usingRecursiveComparison().isEqualTo(new MemberInfo.MemberUpdateResponse("회원 정보 업데이트 성공")); + } + + @DisplayName("withdraw 메서드가 유효한 요청이 들어왔을 때 MemberService의 withdrawal 메서드를 잘 호출하는지") + @Test + void WhenWithdraw_ThenShouldReturnValidDleteResponse() { + Long memberId = 1L; + + var result = memberFacade.withdraw(memberId); + verify(memberService).withdrawal(memberId); + + assertThat(result).isNotNull() + .isInstanceOf(MemberInfo.MemberDeleteResponse.class) + .usingRecursiveComparison().isEqualTo(new MemberInfo.MemberDeleteResponse("회원 탈퇴 성공")); + } + + + @DisplayName("nicknameDuplicateCheck 메서드가 유효한 요청이 들어왔을 때 MemberService의 nicknameDuplicateCheck 메서드를 잘 호출하는지") + @Test + void nicknameDuplicateCheck() { + // Given + String nickname = "nickname"; + MemberInfo.NicknameCheckInfo mockNicknameCheckInfo = new MemberInfo.NicknameCheckInfo(true); + + when(memberService.nicknameDuplicateCheck(nickname)).thenReturn(mockNicknameCheckInfo); + + // When + var result = memberFacade.nicknameDuplicateCheck(nickname); + + // Then + assertThat(result).isNotNull() + .isInstanceOf(MemberInfo.NicknameCheckInfo.class) + .usingRecursiveComparison().isEqualTo(mockNicknameCheckInfo); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/member/application/MemberServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/member/application/MemberServiceTest.java deleted file mode 100644 index 83c3fbe40..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/member/application/MemberServiceTest.java +++ /dev/null @@ -1,204 +0,0 @@ -package kr.co.yigil.member.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import kr.co.yigil.file.FileUploadEvent; -import kr.co.yigil.follow.application.FollowRedisIntegrityService; -import kr.co.yigil.follow.domain.Follow; -import kr.co.yigil.follow.domain.FollowCount; -import kr.co.yigil.follow.domain.repository.FollowRepository; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.member.domain.repository.MemberRepository; -import kr.co.yigil.member.dto.request.MemberUpdateRequest; -import kr.co.yigil.member.dto.response.MemberDeleteResponse; -import kr.co.yigil.member.dto.response.MemberFollowerListResponse; -import kr.co.yigil.member.dto.response.MemberFollowingListResponse; -import kr.co.yigil.member.dto.response.MemberInfoResponse; -import kr.co.yigil.member.dto.response.MemberUpdateResponse; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.post.domain.repository.PostRepository; -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.context.ApplicationEventPublisher; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.mock.web.MockMultipartFile; - -public class MemberServiceTest { - - @Mock - private MemberRepository memberRepository; - - @Mock - private PostRepository postRepository; - - @InjectMocks - private MemberService memberService; - - @Mock - private RedisTemplate redisTemplate; - - @Mock - private FollowRepository followRepository; - - @Mock - private FollowRedisIntegrityService followRedisIntegrityService; - - @Mock - private ApplicationEventPublisher applicationEventPublisher; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - } - - @DisplayName("getMemberInfo 메서드에 유효한 사용자ID가 주어졌을 때 사용자 정보가 잘 반환되는지") - @Test - void whenGetMemberInfo_thenReturnsMemberInfoResponse_withValidMemberInfo() { - Long memberId = 1L; - Member mockMember = new Member("kiit0901@gmail.com", "123456", "stone", "profile.jpg", "kakao"); - List mockPostList = new ArrayList<>(); - FollowCount mockFollowCount = new FollowCount(1L, 0, 0); - - when(memberRepository.findById(memberId)).thenReturn(Optional.of(mockMember)); - when(postRepository.findAllByMember(mockMember)).thenReturn(mockPostList); - when(followRedisIntegrityService.ensureFollowCounts(mockMember)).thenReturn(mockFollowCount); - - doAnswer(invocation -> { - FileUploadEvent event = invocation.getArgument(0); - event.getCallback().accept("mockUrl"); - return null; - }).when(applicationEventPublisher).publishEvent(any(FileUploadEvent.class)); - - MemberInfoResponse response = memberService.getMemberInfo(memberId); - - assertThat(response).isNotNull(); - assertThat(response.getNickname()).isEqualTo("stone"); - assertThat(response.getProfileImageUrl()).isEqualTo("profile.jpg"); - } - - @DisplayName("getMemberInfo 메서드에 잘못된 사용자ID가 주어졌을 때 예외가 잘 발생하는지") - @Test - void whenGetMemberInfo_thenThrowsException_withInvalidMemberInfo() { - Long invalidMemberId = 2L; - - when(memberRepository.findById(invalidMemberId)).thenReturn(Optional.empty()); - - assertThrows(BadRequestException.class, () -> memberService.getMemberInfo(invalidMemberId)); - } - - @DisplayName("updateMemberInfo 메서드가 유효한 사용자 Id가 주어졌을 때 이벤트가 잘 발행되는지") - @Test - void whenUpdateMemberInfo_thenReturnsUpdateInfoAndPublishEvent_withValidMemberInfo() { - Long validMemberId = 1L; - MemberUpdateRequest request = mock(MemberUpdateRequest.class); - MockMultipartFile file = new MockMultipartFile("file", "filename.jpg", "image/jpeg", new byte[10]); - when(request.getProfileImageFile()).thenReturn(file); - - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - when(memberRepository.findById(validMemberId)).thenReturn(Optional.of(mockMember)); - when(memberRepository.save(mockMember)).thenReturn(mockMember); - - doAnswer(invocation -> { - FileUploadEvent event = invocation.getArgument(0); - event.getCallback().accept("mockUrl"); - return null; - }).when(applicationEventPublisher).publishEvent(any(FileUploadEvent.class)); - - MemberUpdateResponse response = memberService.updateMemberInfo(validMemberId, request); - - verify(applicationEventPublisher).publishEvent(any(FileUploadEvent.class)); - assertEquals("회원 정보 업데이트 성공", response.getMessage()); - } - - @DisplayName("updateMemberInfo 메서드가 유효하지 않은 사용자 Id가 주어졌을 때 예외가 잘 발생하는지") - @Test - void whenUpdateMemberInfo_thenThrowsException_withInvalidMemberInfo() { - Long invalidMemberId = 1L; - MemberUpdateRequest request = new MemberUpdateRequest(); - when(memberRepository.findById(invalidMemberId)).thenReturn(Optional.empty()); - - assertThrows(BadRequestException.class, () -> memberService.updateMemberInfo(invalidMemberId, request)); - } - - @DisplayName("withdraw 메서드가 유효한 사용자 ID가 주어졌을 때 회원 탈퇴가 잘 동작하는지") - @Test - void whenWithdraw_shouldDeleteMember_withValidMemberInfo() { - Long validMemberId = 1L; - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - when(memberRepository.findById(validMemberId)).thenReturn(Optional.of(mockMember)); - - MemberDeleteResponse response = memberService.withdraw(validMemberId); - - verify(memberRepository).delete(mockMember); - assertEquals("회원 탈퇴 성공", response.getMessage()); - } - - @DisplayName("withdraw 메서드가 유효하지 않은 사용자 ID가 주어졌을 때 예외를 잘 발생시키는지") - @Test - void whenWithdraw_shouldThrowException_withInvalidMemberInfo() { - Long invalidMemberId = 1L; - when(memberRepository.findById(invalidMemberId)).thenReturn(Optional.empty()); - - assertThrows(BadRequestException.class, () -> memberService.withdraw(invalidMemberId)); - } - - @DisplayName("getFollowerList 메서드가 유효한 사용자 ID가 주어졌을 때 팔로워 목록을 잘 반환하는지") - @Test - void whenGetFollowerList_shouldReturnFollowerList_withValidMemberInfo() { - Long validMemberId = 1L; - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - List mockFollows = new ArrayList<>(); - - Member follower1 = new Member(2L, "follower1@gmail.com", "123456", "follower1", "profile1.jpg", SocialLoginType.KAKAO); - Member follower2 = new Member(3L, "follower2@gmail.com", "123456", "follower2", "profile2.jpg", SocialLoginType.KAKAO); - mockFollows.add(new Follow(follower1, mockMember)); - mockFollows.add(new Follow(follower2, mockMember)); - - when(memberRepository.findById(validMemberId)).thenReturn(Optional.of(mockMember)); - when(followRepository.findAllByFollowing(mockMember)).thenReturn(mockFollows); - - MemberFollowerListResponse response = memberService.getFollowerList(validMemberId); - - assertThat(response).isNotNull(); - assertThat(response.getFollowerList()).hasSize(2); - assertThat(response.getFollowerList()).extracting("nickname").contains("follower1", "follower2"); - } - - @DisplayName("getFollowingList 메서드가 유효한 사용자 ID가 주어졌을 때 팔로잉 목록을 잘 반혼하는지") - @Test - void whenGetFollowingList_shouldReturnFollowingList_withValidMemberInfo() { - Long validMemberId = 1L; - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - List mockFollows = new ArrayList<>(); - - Member following1 = new Member(2L, "following1@gmail.com", "123456", "following1", "profile1.jpg", SocialLoginType.KAKAO); - Member following2 = new Member(3L, "following2@gmail.com", "123456", "following2", "profile2.jpg", SocialLoginType.KAKAO); - mockFollows.add(new Follow(mockMember, following1)); - mockFollows.add(new Follow(mockMember, following2)); - - when(memberRepository.findById(validMemberId)).thenReturn(Optional.of(mockMember)); - when(followRepository.findAllByFollower(mockMember)).thenReturn(mockFollows); - - MemberFollowingListResponse response = memberService.getFollowingList(validMemberId); - - assertThat(response).isNotNull(); - assertThat(response.getFollowingList()).hasSize(2); - assertThat(response.getFollowingList()).extracting("nickname").contains("following1", "following2"); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/member/domain/MemberServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/member/domain/MemberServiceImplTest.java new file mode 100644 index 000000000..404423cf0 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/member/domain/MemberServiceImplTest.java @@ -0,0 +1,98 @@ +package kr.co.yigil.member.domain; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +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.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.follow.domain.FollowCount; +import kr.co.yigil.follow.domain.FollowReader; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberCommand.MemberUpdateRequest; +import kr.co.yigil.region.domain.RegionReader; + +@ExtendWith(MockitoExtension.class) +class MemberServiceImplTest { + + @InjectMocks + private MemberServiceImpl memberService; + + @Mock + private MemberReader memberReader; + @Mock + private MemberStore memberStore; + @Mock + private FollowReader followReader; + @Mock + private FileUploader fileUploader; + + @Mock + private RegionReader regionReader; + + @DisplayName("retrieveMemberInfo 를 호출했을 때 멤버 정보 조회가 잘 되는지 확인") + @Test + void WhenRetrieveMemberInfo_ThenReturnMemberInfoMain() { + Long memberId = 1L; + Member mockMember = mock(Member.class); + FollowCount followCount = mock(FollowCount.class); + + when(memberReader.getMember(anyLong())).thenReturn(mockMember); + when(followReader.getFollowCount(anyLong())).thenReturn(followCount); + + var result = memberService.retrieveMemberInfo(memberId); + + assertThat(result).isNotNull().isInstanceOf(MemberInfo.Main.class); + } + + @DisplayName("withdrawal 를 호출했을 때 회원 탈퇴가 잘 되는지 확인") + @Test + void WhenWithdrawal_ThenShouldNotOccuredError() { + Long memberId = 1L; + + memberService.withdrawal(memberId); + verify(memberStore).deleteMember(memberId); + } + + @DisplayName("updateMemberInfo 를 호출했을 때 회원 정보 업데이트가 잘 되는지 확인") + @Test + void WhenUpdateMemberInfo_ThenShouldNotOccuredError() { + Long memberId = 1L; + MultipartFile mockFile = new MockMultipartFile("file", "UploadOriginalFileName.jpg", + "image/jpeg", + "test".getBytes()); + MemberCommand.MemberUpdateRequest request = new MemberUpdateRequest("nickname", "10대", "여성", + mockFile, List.of(1L, 2L, 3L)); + + Member mockMember = mock(Member.class); + + AttachFile mockAttachFile = mock(AttachFile.class); + + when(memberReader.getMember(anyLong())).thenReturn(mockMember); + when(fileUploader.upload(mockFile)).thenReturn(mockAttachFile); + + memberService.updateMemberInfo(memberId, request); + verify(mockMember).updateMemberInfo(anyString(), anyString(), anyString(), any(AttachFile.class), anyList()); + } + + @DisplayName("nicknameDuplicateCheck 를 호출했을 때 닉네임 중복 체크가 잘 되는지 확인") + @Test + void whenNicknameDuplicateCheck() { + String nickname = "nickname"; + when(memberReader.existsByNickname(nickname)).thenReturn(false); + MemberInfo.NicknameCheckInfo result = memberService.nicknameDuplicateCheck(nickname); + assertThat(result).isNotNull().isInstanceOf(MemberInfo.NicknameCheckInfo.class); + assertThat(result.isAvailable()).isTrue(); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/member/infrastructure/MemberReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/member/infrastructure/MemberReaderImplTest.java new file mode 100644 index 000000000..7ac987dc6 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/member/infrastructure/MemberReaderImplTest.java @@ -0,0 +1,89 @@ +package kr.co.yigil.member.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +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 kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +class MemberReaderImplTest { + + @InjectMocks + private MemberReaderImpl memberReader; + + @Mock + private MemberRepository memberRepository; + + @DisplayName("getMember 메서드가 올바른 Member를 반환하는지") + @Test + void WhenGetMember_ThenShouldReturnMember() { + Long memberid = 1L; + Member member = mock(Member.class); + + when(memberRepository.findById(memberid)).thenReturn(Optional.of(member)); + + var result = memberReader.getMember(memberid); + + assertThat(result).isEqualTo(member); + } + + @DisplayName("validateMember 메서드가 올바른 응답을 반환하는지") + @Test + void WhenValidateMember_ThenShouldNotOccuredErrror() { + Long memberid = 1L; + when(memberRepository.existsById(memberid)).thenReturn(true); + + memberReader.validateMember(memberid); + } + + @DisplayName("findMemberBySocialLoginIdAndSocialLoginType 메서드가 올바른 응답을 반환하는지") + @Test + void WhenFindMemberBySocialLoginIdAndSocialLoginType_ThenShouldReturnMember() { + String socialLoginId = "socialLoginId"; + var socialLoginType = mock(SocialLoginType.class); + Member member = mock(Member.class); + + when(memberRepository.findMemberBySocialLoginIdAndSocialLoginType(socialLoginId, socialLoginType)).thenReturn(Optional.of(member)); + + var result = memberReader.findMemberBySocialLoginIdAndSocialLoginType(socialLoginId, socialLoginType); + + assertThat(result).isEqualTo(Optional.of(member)); + } + + @DisplayName("findMemberByEmailAndSocialLoginType 메서드가 올바른 응답을 반환하는지") + @Test + void whenFindMemberByEmailAndSocialLoginType_ThenShouldReturnMember() { + String email = "email"; + var socialLoginType = mock(SocialLoginType.class); + Member member = mock(Member.class); + + when(memberRepository.findMemberByEmailAndSocialLoginType(email, + socialLoginType)).thenReturn(Optional.of(member)); + + var result = memberReader.findMemberByEmailAndSocialLoginType(email, socialLoginType); + + assertThat(result).isEqualTo(Optional.of(member)); + } + + @DisplayName("existsByNickname 메서드가 올바른 응답을 반환하는지") + @Test + void whenExistsByNickname_thenShouldReturnBoolean() { + String nickname = "nickname"; + when(memberRepository.existsByNickname(nickname)).thenReturn(true); + + var result = memberReader.existsByNickname(nickname); + + assertThat(result).isTrue(); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/member/infrastructure/MemberStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/member/infrastructure/MemberStoreImplTest.java new file mode 100644 index 000000000..9a93bc72e --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/member/infrastructure/MemberStoreImplTest.java @@ -0,0 +1,49 @@ +package kr.co.yigil.member.infrastructure; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.repository.MemberRepository; +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; + + +@ExtendWith(MockitoExtension.class) +class MemberStoreImplTest { + @InjectMocks + private MemberStoreImpl memberStore; + + @Mock + private MemberRepository memberRepository; + + + @DisplayName("deleteMember 메서드가 올바르게 동작하는지") + @Test + void WhenDeleteMember_ThenShouldNotThrowError() { + Long memberId = 1L; + + memberStore.deleteMember(memberId); + + verify(memberRepository).deleteById(memberId); + + } + + @DisplayName("save 메서드가 올바르게 동작하는지") + @Test + void WhenMemberStoreSave_ThenShouldReturnSavedMember() { + Member member = mock(Member.class); + + when(memberRepository.save(member)).thenReturn(member); + + var result = memberStore.save(member); + + assertThat(result).isEqualTo(member); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/member/interfaces/controller/MemberApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/member/interfaces/controller/MemberApiControllerTest.java new file mode 100644 index 000000000..de5aca6b7 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/member/interfaces/controller/MemberApiControllerTest.java @@ -0,0 +1,261 @@ +package kr.co.yigil.member.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.web.config.EnableSpringDataWebSupport; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.multipart.MultipartFile; + +import kr.co.yigil.member.application.MemberFacade; +import kr.co.yigil.member.domain.MemberInfo; +import kr.co.yigil.member.interfaces.dto.MemberDto; +import kr.co.yigil.member.interfaces.dto.MemberDto.Main; +import kr.co.yigil.member.interfaces.dto.mapper.MemberDtoMapper; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(MemberApiController.class) +@EnableSpringDataWebSupport +class MemberApiControllerTest { + + @MockBean + private MemberFacade memberFacade; + + @MockBean + private MemberDtoMapper memberDtoMapper; + + private MockMvc mockMvc; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation)) + .build(); + } + + @DisplayName("내 정보 조회가 잘 되는지") + @Test + void getMyInfo_ShouldReturnOk() throws Exception { + + MemberDto.FavoriteRegion favoriteRegion1 = MemberDto.FavoriteRegion.builder() + .id(1L) + .name("서울") + .build(); + MemberDto.FavoriteRegion favoriteRegion2 = MemberDto.FavoriteRegion.builder() + .id(2L) + .name("경기") + .build(); + + MemberDto.Main response = Main.builder() + .memberId(1L) + .email("test@yigil.co.kr") + .nickname("test user") + .profileImageUrl("https://cdn.igil.co.kr/images/profile.jpg") + .favoriteRegions(List.of(favoriteRegion1, favoriteRegion2)) + .followerCount(10) + .followingCount(20) + .build(); + + when(memberFacade.getMemberInfo(anyLong())).thenReturn(mock(MemberInfo.Main.class)); + when(memberDtoMapper.of(any(MemberInfo.Main.class))).thenReturn(response); + + mockMvc.perform(get("/api/v1/members")) + .andExpect(status().isOk()) + .andDo(document( + "members/get-my-info", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + fieldWithPath("member_id").description("회원 ID"), + fieldWithPath("email").description("이메일"), + fieldWithPath("nickname").description("닉네임"), + fieldWithPath("profile_image_url").description("프로필 이미지 URL"), + fieldWithPath("favorite_regions").description("좋아하는 지역리스트"), + fieldWithPath("favorite_regions[].id").description("좋아하는 지역 ID"), + fieldWithPath("favorite_regions[].name").description("좋아하는 지역 이름"), + fieldWithPath("following_count").description("팔로잉 수"), + fieldWithPath("follower_count").description("팔로워 수") + ) + )); + + verify(memberFacade).getMemberInfo(anyLong()); + } + + @DisplayName("내 정보 업데이트가 잘 되는지") + @Test + void updateMyInfo_ShouldReturnOk() throws Exception { + + MultipartFile multipartFile = new MockMultipartFile( + "profileImageFile", // 필드 이름 + "hello.txt", // 원본 파일 이름 + "image/jpeg", // 컨텐츠 타입 + "Hello, World!".getBytes() // 파일 내용 + ); + + when(memberFacade.updateMemberInfo(anyLong(), any())).thenReturn( + mock(MemberInfo.MemberUpdateResponse.class)); + when(memberDtoMapper.of(any(MemberInfo.MemberUpdateResponse.class))).thenReturn( + MemberDto.MemberUpdateResponse.builder() + .message("회원 정보 업데이트 성공") + .build() + ); + String requestJson = "{\n" + + " \"nickname\": \"nickname\",\n" + + " \"age\": \"10대\",\n" + + " \"gender\": \"남성\",\n" + + " \"favoriteRegionIds\": [1, 2, 3]\n" + + "}"; + + mockMvc.perform(multipart("/api/v1/members") + .file("profileImageFile", multipartFile.getBytes()) +// .param("nickname", "nickname") +// .param("age", "10대") +// .param("gender", "남성") +// .param("favoriteRegionIds", "1", "2", "3") + .content(requestJson) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isOk()) + .andDo(document( + "members/update-my-info", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("profileImageFile").description("프로필 이미지 파일") + ), + responseFields( + fieldWithPath("message").description("메시지") + ) + )); + } + + @DisplayName("회원 탈퇴가 잘 되는지") + @Test + void WhenWithdraw_ThenShouldReturnOk() throws Exception { + MemberDto.MemberDeleteResponse response = MemberDto.MemberDeleteResponse.builder() + .message("회원 탈퇴 성공") + .build(); + + when(memberFacade.withdraw(anyLong())).thenReturn( + mock(MemberInfo.MemberDeleteResponse.class)); + when(memberDtoMapper.of(any(MemberInfo.MemberDeleteResponse.class))).thenReturn(response); + + mockMvc.perform(delete("/api/v1/members")) + .andExpect(status().isOk()) + .andDo(document( + "members/withdraw", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + fieldWithPath("message").description("메시지") + ) + )); + + verify(memberFacade).withdraw(anyLong()); + } + + @DisplayName("회원 정보 조회가 잘 되는지") + @Test + void WhenGetMemberInfo_ThenShouldReturnOk() throws Exception { + MemberDto.FavoriteRegion favoriteRegion1 = MemberDto.FavoriteRegion.builder() + .id(1L) + .name("서울") + .build(); + MemberDto.FavoriteRegion favoriteRegion2 = MemberDto.FavoriteRegion.builder() + .id(2L) + .name("경기") + .build(); + + Long memberId = 1L; + MemberDto.Main response = Main.builder() + .memberId(1L) + .email("test@yigil.co.kr") + .nickname("test user") + .profileImageUrl("https://cdn.yigil.co.kr/images/profile.jpg") + .favoriteRegions(List.of(favoriteRegion1, favoriteRegion2)) + .followerCount(10) + .followingCount(20) + .build(); + + when(memberFacade.getMemberInfo(anyLong())).thenReturn(mock(MemberInfo.Main.class)); + when(memberDtoMapper.of(any(MemberInfo.Main.class))).thenReturn(response); + + mockMvc.perform(get("/api/v1/members/{memberId}", memberId)) + .andExpect(status().isOk()) + .andDo(document( + "members/get-member-info", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("memberId").description("회원 ID") + ), + responseFields( + fieldWithPath("member_id").description("회원 ID"), + fieldWithPath("email").description("이메일"), + fieldWithPath("nickname").description("닉네임"), + fieldWithPath("profile_image_url").description("프로필 이미지 URL"), + fieldWithPath("favorite_regions").description("좋아하는 지역리스트"), + fieldWithPath("favorite_regions[].id").description("좋아하는 지역 ID"), + fieldWithPath("favorite_regions[].name").description("좋아하는 지역 이름"), + fieldWithPath("following_count").description("팔로잉 수"), + fieldWithPath("follower_count").description("팔로워 수") + ) + )); + + verify(memberFacade).getMemberInfo(anyLong()); + } + + @DisplayName("닉네임 중복 체크가 잘 되는지") + @Test + void whenNicknameDuplicateCheck_thenShouldReturn200AndResponse() throws Exception{ + + + MemberInfo.NicknameCheckInfo checkInfo = new MemberInfo.NicknameCheckInfo(true); + MemberDto.NicknameCheckResponse response = MemberDto.NicknameCheckResponse.builder() + .available(true) + .build(); + + when(memberFacade.nicknameDuplicateCheck(anyString())).thenReturn(checkInfo); + when(memberDtoMapper.of(any(MemberInfo.NicknameCheckInfo.class))).thenReturn(response); + + mockMvc.perform(post("/api/v1/members/nickname_duplicate_check") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"nickname\": \"nickname\"}")) + .andExpect(status().isOk()) + .andDo(document( + "members/nickname-duplicate-check", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("nickname").description("닉네임") + ), + responseFields( + fieldWithPath("available").description("사용 가능 여부") + ) + )); + + verify(memberFacade).nicknameDuplicateCheck(anyString()); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/member/presentation/MemberControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/member/presentation/MemberControllerTest.java deleted file mode 100644 index c38630a7e..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/member/presentation/MemberControllerTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package kr.co.yigil.member.presentation; - -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - - -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.member.application.MemberService; -import kr.co.yigil.member.dto.request.MemberUpdateRequest; -import kr.co.yigil.member.dto.response.MemberDeleteResponse; -import kr.co.yigil.member.dto.response.MemberInfoResponse; -import kr.co.yigil.member.dto.response.MemberUpdateResponse; -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.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@ExtendWith(SpringExtension.class) -@WebMvcTest(MemberController.class) -public class MemberControllerTest { - - private MockMvc mockMvc; - - @MockBean - private MemberService memberService; - - @InjectMocks - private MemberController memberController; - - @BeforeEach - void setUp(WebApplicationContext webApplicationContext) { - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } - - @DisplayName("내 정보가 조회될 때 200 응답과 response가 잘 반환되는지") - @Test - void whenGetMyInfo_thenReturns200AndMemberInfoResponse() throws Exception { - MemberInfoResponse mockResponse = new MemberInfoResponse(); - Accessor accessor = Accessor.member(1L); - - given(memberService.getMemberInfo(accessor.getMemberId())).willReturn(mockResponse); - - mockMvc.perform(get("/api/v1/member") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @DisplayName("사용자 정보를 조회할 때 200 응답과 response가 잘 반환되는지") - @Test - void whenGetMemberInfo_thenReturns200AndMemberInfoResponse() throws Exception { - MemberInfoResponse mockResponse = new MemberInfoResponse(); - Long memberId = 1L; - - given(memberService.getMemberInfo(memberId)).willReturn(mockResponse); - - mockMvc.perform(get("/api/v1/member/1") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @DisplayName("내 정보 업데이트 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenUpdateMyInfo_thenReturns200AndMemberUpdateResponse() throws Exception { - MemberUpdateResponse mockResponse = new MemberUpdateResponse(); - MemberUpdateRequest mockRequest = new MemberUpdateRequest(); - Accessor accessor = Accessor.member(1L); - - given(memberService.updateMemberInfo(accessor.getMemberId(), mockRequest)).willReturn(mockResponse); - - MockMultipartFile file = new MockMultipartFile("file", "filename.jpg", "image/jpeg", new byte[10]); - - - mockMvc.perform(multipart("/api/v1/member") - .file(file) - .param("nickname", "nickname") - .sessionAttr("memberId", accessor.getMemberId())) - .andExpect(status().isOk()); - } - - @DisplayName("회원 탈퇴 요청이 왔을 때 200 응답과 Response가 잘 반환되는지") - @Test - void whenWithdraw_thenReturns200AndMemberDeleteResponse() throws Exception { - MemberDeleteResponse mockResponse = new MemberDeleteResponse(); - Accessor accessor = Accessor.member(1L); - - given(memberService.withdraw(accessor.getMemberId())).willReturn(mockResponse); - - mockMvc.perform(delete("/api/v1/member") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/notification/application/NotificationFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/notification/application/NotificationFacadeTest.java new file mode 100644 index 000000000..6f51f65a2 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/notification/application/NotificationFacadeTest.java @@ -0,0 +1,55 @@ +package kr.co.yigil.notification.application; + +import kr.co.yigil.notification.domain.Notification; +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.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.http.codec.ServerSentEvent; +import reactor.core.publisher.Flux; +import kr.co.yigil.notification.domain.NotificationService; + +import static org.mockito.Mockito.*; + +class NotificationFacadeTest { + + @Mock + private NotificationService notificationService; + + @InjectMocks + private NotificationFacade notificationFacade; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + @DisplayName("getNotificationStream 메서드가 NotificationService의 메서드를 잘 호출하는지") + void getNotificationStream() { + Long memberId = 1L; + Flux> notificationFlux = Flux.empty(); + when(notificationService.getNotificationStream(memberId)).thenReturn(notificationFlux); + + notificationFacade.getNotificationStream(memberId); + + verify(notificationService, times(1)).getNotificationStream(memberId); + } + + @Test + @DisplayName("getNotificationSlice 메서드가 NotificationService의 메서드를 잘 호출하는지") + void getNotificationSlice() { + Long memberId = 1L; + PageRequest pageRequest = PageRequest.of(0, 10); + Slice notificationSlice = mock(Slice.class); + when(notificationService.getNotificationSlice(memberId, pageRequest)).thenReturn(notificationSlice); + + notificationFacade.getNotificationSlice(memberId, pageRequest); + + verify(notificationService, times(1)).getNotificationSlice(memberId, pageRequest); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/notification/application/NotificationServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/notification/application/NotificationServiceTest.java deleted file mode 100644 index 021d66207..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/notification/application/NotificationServiceTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package kr.co.yigil.notification.application; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; - -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.notification.domain.Notification; -import kr.co.yigil.notification.domain.repository.NotificationRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.http.codec.ServerSentEvent; -import reactor.core.publisher.Flux; -import reactor.test.StepVerifier; - -public class NotificationServiceTest { - - @Mock - private NotificationRepository notificationRepository; - - private NotificationService notificationService; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - notificationService = new NotificationService(notificationRepository); - } - - @Test - @DisplayName("알림을 보내고 저장하는지 확인") - void testSendNotification() { - Member member = new Member(1L, "email", "12345678", "nickname", "image.jpg", SocialLoginType.KAKAO); - Notification notification = new Notification(member, "새 알림"); - - notificationService.sendNotification(notification); - - verify(notificationRepository).save(any(Notification.class)); - } - - @Test - @DisplayName("특정 회원 ID에 대한 알림 스트림을 올바르게 반환하는지 확인") - void testGetNotificationStream() { - Long memberId = 1L; - Member member = new Member(memberId, "email@example.com", "1234", "nickname", "profile.jpg", SocialLoginType.KAKAO); - Notification notification = new Notification(member, "새 알림"); - - notificationService.sendNotification(notification); - - Flux> stream = notificationService.getNotificationStream(memberId); - - StepVerifier.create(stream) - .expectNextMatches(sse -> sse.data().equals(notification)) - .thenCancel() - .verify(); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/notification/domain/NotificationServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/notification/domain/NotificationServiceImplTest.java new file mode 100644 index 000000000..75347278f --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/notification/domain/NotificationServiceImplTest.java @@ -0,0 +1,76 @@ +package kr.co.yigil.notification.domain; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.domain.MemberReader; +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.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.Sinks.EmitResult; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +class NotificationServiceImplTest { + + @Mock + private NotificationReader notificationReader; + + @Mock + private NotificationSender notificationSender; + + @InjectMocks + private NotificationServiceImpl notificationService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("유효한 파라미터로 getNotificationStream 메서드가 잘 호출되는지") + @Test + void shouldCallGetNotificationStreamMethodCorrectly_givenValidParameters() { + Long notifierId = 1L; + when(notificationReader.getNotificationStream(anyLong())).thenReturn(Flux.empty()); + + notificationService.getNotificationStream(notifierId); + + verify(notificationReader, times(1)).getNotificationStream(notifierId); + } + + + @DisplayName("유효한 파라미터로 sendNotification 메서드가 잘 호출되는지") + @Test + void shouldCallSendNotificationMethodCorrectly_givenValidParameters() { + Long senderId = 1L; + Long receiverId = 2L; + NotificationType notificationType = NotificationType.FOLLOW; + + notificationService.sendNotification(notificationType, senderId, receiverId); + + verify(notificationSender).sendNotification(notificationType, senderId, receiverId); + } + + @DisplayName("When valid parameters, getNotificationSlice method should be called correctly") + @Test + void shouldCallGetNotificationSliceMethodCorrectly_givenValidParameters() { + Slice slice = mock(Slice.class); + when(notificationReader.getNotificationSlice(anyLong(), any(PageRequest.class))).thenReturn( + slice); + + notificationService.getNotificationSlice(1L, PageRequest.of(0, 10)); + + verify(notificationReader, times(1)).getNotificationSlice(anyLong(), + any(PageRequest.class)); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/notification/infrastructure/NotificationReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/notification/infrastructure/NotificationReaderImplTest.java new file mode 100644 index 000000000..d4db4174d --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/notification/infrastructure/NotificationReaderImplTest.java @@ -0,0 +1,80 @@ +package kr.co.yigil.notification.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import io.lettuce.core.protocol.DemandAware.Sink; +import java.util.ArrayList; +import java.util.List; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +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.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.Sinks.EmitResult; +import reactor.test.StepVerifier; + +public class NotificationReaderImplTest { + + @Mock + private NotificationRepository notificationRepository; + + @InjectMocks + private NotificationReaderImpl notificationReader; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("getNotificationStream 메서드가 올바른 Flux를 반환하는지") + @Test + void whenGetNotificationStream_thenReturnsCorrectFlux() { + Long memberId = 1L; + Member member = new Member(memberId, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", + SocialLoginType.KAKAO); + Notification notification = new Notification(member, "Notification content"); + + Sinks.Many realSink = Sinks.many().multicast().onBackpressureBuffer(); + realSink.tryEmitNext(notification); + + ReflectionTestUtils.setField(notificationReader, "sink", realSink); + + Flux> actualFlux = notificationReader.getNotificationStream( + memberId); + + StepVerifier.create(actualFlux) + .expectNextMatches(sse -> sse.data().equals(notification)) + .thenCancel() + .verify(); + } + + @DisplayName("getNotificationSlice 메서드가 올바른 Slice를 반환하는지") + @Test + void whenGetNotificationSlice_thenReturnsCorrectSlice() { + Long memberId = 1L; + List notifications = new ArrayList<>(); + Slice expectedSlice = new SliceImpl<>(notifications); + + when(notificationRepository.findAllByMemberId(memberId, Pageable.unpaged())).thenReturn( + expectedSlice); + + Slice actualSlice = notificationReader.getNotificationSlice(memberId, + Pageable.unpaged()); + + assertEquals(expectedSlice, actualSlice); + } + +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/notification/infrastructure/NotificationSenderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/notification/infrastructure/NotificationSenderImplTest.java new file mode 100644 index 000000000..b4d254b1f --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/notification/infrastructure/NotificationSenderImplTest.java @@ -0,0 +1,77 @@ +package kr.co.yigil.notification.infrastructure; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.domain.NotificationFactory; +import kr.co.yigil.notification.domain.NotificationSender; +import kr.co.yigil.notification.domain.NotificationStore; +import kr.co.yigil.notification.domain.NotificationType; +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.util.ReflectionTestUtils; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.Sinks.EmitResult; +import reactor.test.StepVerifier; + +class NotificationSenderImplTest { + + @Mock + private MemberReader memberReader; + + @Mock + private NotificationStore notificationStore; + + @Mock + private NotificationFactory notificationFactory; + + @Mock + private Sinks.Many sink = Sinks.many().multicast().onBackpressureBuffer(); + + @InjectMocks + private NotificationSenderImpl notificationSender; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + @DisplayName("유효한 파라미터로 sendNotification 메서드가 잘 호출되는지") + @Test + void shouldCallSendNotificationMethodCorrectly_givenValidParameters() { + Long senderId = 1L; + Long receiverId = 2L; + NotificationType notificationType = NotificationType.FOLLOW; + + Member sender = new Member(senderId,"shin@gmail.com", "123456", "sendernickname", "profile.jpg", + SocialLoginType.KAKAO); + Member receiver = new Member(receiverId,"shidn@gmail.com", "1d23456", "receivernickname", "profilde.jpg", + SocialLoginType.KAKAO); + Notification notification = new Notification(receiver, "message"); + + when(memberReader.getMember(senderId)).thenReturn(sender); + when(memberReader.getMember(receiverId)).thenReturn(receiver); + when(notificationFactory.createNotification(notificationType, sender, receiver)).thenReturn(notification); + when(sink.tryEmitNext(any(Notification.class))).thenReturn(EmitResult.OK); + ReflectionTestUtils.setField(notificationSender, "sink", sink); + + notificationSender.sendNotification(notificationType, senderId, receiverId); + + verify(memberReader, times(2)).getMember(anyLong()); + verify(notificationFactory).createNotification(any(NotificationType.class), any(Member.class), any(Member.class)); + verify(notificationStore).store(any(Notification.class)); + verify(sink, times(1)).tryEmitNext(any(Notification.class)); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/notification/infrastructure/NotificationStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/notification/infrastructure/NotificationStoreImplTest.java new file mode 100644 index 000000000..0a90abceb --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/notification/infrastructure/NotificationStoreImplTest.java @@ -0,0 +1,39 @@ +package kr.co.yigil.notification.infrastructure; + +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + + +public class NotificationStoreImplTest { + + @Mock + private NotificationRepository notificationRepository; + + @InjectMocks + private NotificationStoreImpl notificationStore; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void whenStore_thenSaveIsCalled() { + Long memberId = 1L; + Member member = new Member(memberId, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); + Notification notification = new Notification(member, "Notification content"); + + notificationStore.store(notification); + + verify(notificationRepository, times(1)).save(notification); + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/notification/interfaces/controller/NotificationApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/notification/interfaces/controller/NotificationApiControllerTest.java new file mode 100644 index 000000000..e411daebd --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/notification/interfaces/controller/NotificationApiControllerTest.java @@ -0,0 +1,128 @@ +package kr.co.yigil.notification.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseBody; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +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.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.notification.application.NotificationFacade; +import kr.co.yigil.notification.domain.Notification; +import kr.co.yigil.notification.interfaces.controller.NotificationApiController; +import kr.co.yigil.notification.interfaces.dto.NotificationInfoDto; +import kr.co.yigil.notification.interfaces.dto.mapper.NotificationMapper; +import kr.co.yigil.notification.interfaces.dto.response.NotificationsResponse; +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.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.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import reactor.core.publisher.Flux; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(NotificationApiController.class) +@AutoConfigureRestDocs +public class NotificationApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private NotificationFacade notificationFacade; + + @MockBean + private NotificationMapper notificationMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation)).build(); + } + + @DisplayName("Notificationstream이 올바르게 동작하는지 테스트") + @Test + void whenStreamNotification_thenReturns200() throws Exception { + Member member = new Member(1L, "email", "12345678", "nickname", "image.jpg", SocialLoginType.KAKAO); + Notification notification = new Notification(member, "새로운 알림입니다."); + ServerSentEvent sse = ServerSentEvent.builder(notification).id("1").event("test event").build(); + + when(notificationFacade.getNotificationStream(anyLong())).thenReturn(Flux.just(sse)); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/notifications/stream")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(content().contentType(MediaType.TEXT_EVENT_STREAM_VALUE)) + .andDo(document( + "notifications/stream-notification", + getDocumentRequest(), + getDocumentResponse(), + responseBody() + )); + + verify(notificationFacade).getNotificationStream(anyLong()); + } + + @DisplayName("GetNotifications가 올바르게 동작하는지 테스트") + @Test + void whenGetNotifications_thenReturns200AndNotificationsResponse() throws Exception { + Member member = new Member(1L, "email", "12345678", "nickname", "image.jpg", + SocialLoginType.KAKAO); + Notification notification = new Notification(member, "새로운 알림입니다."); + when(notificationFacade.getNotificationSlice(anyLong(), any(PageRequest.class))).thenReturn(new SliceImpl<>(List.of(notification))); + NotificationInfoDto notificationInfoDto = new NotificationInfoDto("message", "createDate"); + when(notificationMapper.notificationSliceToNotificationsResponse(new SliceImpl<>(List.of(notification)))).thenReturn(new NotificationsResponse(List.of(notificationInfoDto), false)); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/notifications")) + .andExpect(status().isOk()) + .andDo(document( + "notifications/get-notifications", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("현재 페이지").optional(), + parameterWithName("size").description("페이지 크기").optional(), + parameterWithName("sortBy").description("정렬 옵션").optional(), + parameterWithName("sortOrder").description("정렬 순서").optional() + ), + responseFields( + fieldWithPath("has_next").type(JsonFieldType.BOOLEAN).description("다음 페이지가 있는지 여부"), + subsectionWithPath("notifications").description("notification의 정보"), + fieldWithPath("notifications[].message").description("Notification의 메시지"), + fieldWithPath("notifications[].create_date").type(JsonFieldType.STRING).description("Notification의 생성일시") + ) + )); + + verify(notificationFacade).getNotificationSlice(anyLong(), any(PageRequest.class)); + verify(notificationMapper).notificationSliceToNotificationsResponse(new SliceImpl<>(List.of(notification))); + + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/notification/presentation/NotificationControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/notification/presentation/NotificationControllerTest.java deleted file mode 100644 index a9aaf861c..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/notification/presentation/NotificationControllerTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package kr.co.yigil.notification.presentation; - -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.notification.application.NotificationService; -import kr.co.yigil.notification.domain.Notification; -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.Mockito; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.codec.ServerSentEvent; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import org.springframework.test.web.servlet.result.MockMvcResultMatchers; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; -import reactor.core.publisher.Flux; - -@ExtendWith(SpringExtension.class) -@WebMvcTest(NotificationController.class) -public class NotificationControllerTest { - - private MockMvc mockMvc; - - @MockBean - private NotificationService notificationService; - - @BeforeEach - void setUp(WebApplicationContext webApplicationContext) { - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } - - @DisplayName("Notification stream이 올바르게 반환되는지 테스트") - @Test - void testStreamNotifications() throws Exception { - Member member = new Member(1L, "email", "12345678", "nickname", "image.jpg", SocialLoginType.KAKAO); - Notification notification = new Notification(member, "새로운 알림입니다."); - Flux> notificationFlux = Flux.just(ServerSentEvent.builder(notification).build()); - - Mockito.when(notificationService.getNotificationStream(member.getId())).thenReturn(notificationFlux); - - mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/notifications/stream")) - .andExpect(MockMvcResultMatchers.status().isOk()); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/place/application/PlaceFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/place/application/PlaceFacadeTest.java new file mode 100644 index 000000000..b82f181aa --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/place/application/PlaceFacadeTest.java @@ -0,0 +1,200 @@ +package kr.co.yigil.place.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCommand; +import kr.co.yigil.place.domain.PlaceInfo; +import kr.co.yigil.place.domain.PlaceInfo.Detail; +import kr.co.yigil.place.domain.PlaceInfo.Main; +import kr.co.yigil.place.domain.PlaceInfo.MapStaticImageInfo; +import kr.co.yigil.place.domain.PlaceService; +import kr.co.yigil.place.interfaces.dto.request.NearPlaceRequest; +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.Pageable; +import org.springframework.data.domain.Slice; + +@ExtendWith(MockitoExtension.class) +public class PlaceFacadeTest { + + @Mock + private PlaceService placeService; + + @InjectMocks + private PlaceFacade placeFacade; + + @DisplayName("findPlaceStaticImage 메서드가 MapStaticImageInfo를 잘 반환하는지") + @Test + void findPlaceStaticImage_ShouldReturnResponse() { + MapStaticImageInfo mockResponse = mock(MapStaticImageInfo.class); + String placeName = "장소"; + String address = "장소구 장소면 장소리"; + + when(placeService.findPlaceStaticImage(placeName, address)).thenReturn(mockResponse); + + var result = placeFacade.findPlaceStaticImage(placeName, address); + + assertEquals(result, mockResponse); + verify(placeService).findPlaceStaticImage(placeName, address); + } + + @DisplayName("getPopularPlace 메서드가 Response를 잘 반환하는지") + @Test + void getPopularPlace_ShouldReturnResponse() { + Accessor mockAccessor = mock(Accessor.class); + Main mockResponse = mock(Main.class); + + when(placeService.getPopularPlace(mockAccessor)).thenReturn(List.of(mockResponse)); + + var result = placeFacade.getPopularPlace(mockAccessor); + + assertEquals(result, List.of(mockResponse)); + verify(placeService).getPopularPlace(any(Accessor.class)); + } + + @DisplayName("getPopularPlaceMore 메서드가 Response를 잘 반환하는지") + @Test + void getPopularPlaceMore_ShouldReturnResponse() { + Accessor mockAccessor = mock(Accessor.class); + Main mockResponse = mock(Main.class); + + when(placeService.getPopularPlaceMore(mockAccessor)).thenReturn(List.of(mockResponse)); + + var result = placeFacade.getPopularPlaceMore(mockAccessor); + + assertEquals(result, List.of(mockResponse)); + verify(placeService).getPopularPlaceMore(any(Accessor.class)); + } + + @DisplayName("getPopularPlaceByDemographics 메서드가 Response를 잘 반환하는지") + @Test + void getPopularPlaceByDemographics_ShouldReturnResponse() { + Long memberId = 1L; + Main mockResponse = mock(Main.class); + + when(placeService.getPopularPlaceByDemographics(memberId)).thenReturn(List.of(mockResponse)); + + var result = placeFacade.getPopularPlaceByDemographics(memberId); + + assertEquals(result, List.of(mockResponse)); + verify(placeService).getPopularPlaceByDemographics(anyLong()); + } + + @DisplayName("getPopularPlaceByDemographicsMore 메서드가 Response를 잘 반환하는지") + @Test + void getPopularPlaceByDemographicsMore_ShouldReturnResponse() { + Long memberId = 1L; + Main mockResponse = mock(Main.class); + + when(placeService.getPopularPlaceByDemographicsMore(memberId)).thenReturn(List.of(mockResponse)); + + var result = placeFacade.getPopularPlaceByDemographicsMore(memberId); + + assertEquals(result, List.of(mockResponse)); + verify(placeService).getPopularPlaceByDemographicsMore(anyLong()); + } + + @DisplayName("retrievePlaceInfo 메서드가 Response를 잘 반환하는지") + @Test + void retrievePlaceInfo_ShouldReturnResponse() { + Accessor mockAccessor = mock(Accessor.class); + Detail mockResponse = mock(Detail.class); + Long placeId = 1L; + + when(placeService.retrievePlace(placeId, mockAccessor)).thenReturn(mockResponse); + + var result = placeFacade.retrievePlaceInfo(placeId, mockAccessor); + + assertEquals(result, mockResponse); + verify(placeService).retrievePlace(anyLong(), any(Accessor.class)); + } + + @DisplayName("getPlaceRegion 메서드가 Response를 잘 반환하는지") + @Test + void getPlaceRegion_ShouldReturnResponse() { + Accessor mockAccessor = mock(Accessor.class); + Main mockResponse = mock(Main.class); + Long regionId = 1L; + + when(placeService.getPlaceInRegion(regionId, mockAccessor)).thenReturn(List.of(mockResponse)); + + var result = placeFacade.getPlaceInRegion(regionId, mockAccessor); + + assertEquals(result, List.of(mockResponse)); + verify(placeService).getPlaceInRegion(anyLong(), any(Accessor.class)); + } + + @DisplayName("getPlaceRegionMore 메서드가 Response를 잘 반환하는지") + @Test + void getPlaceRegionMore_ShouldReturnResponse() { + Accessor mockAccessor = mock(Accessor.class); + Main mockResponse = mock(Main.class); + Long regionId = 1L; + + when(placeService.getPlaceInRegionMore(regionId, mockAccessor)).thenReturn(List.of(mockResponse)); + + var result = placeFacade.getPlaceInRegionMore(regionId, mockAccessor); + + assertEquals(result, List.of(mockResponse)); + verify(placeService).getPlaceInRegionMore(anyLong(), any(Accessor.class)); + } + + @DisplayName("getNearPlace 메서드가 Response를 잘 반환하는지") + @Test + void getNearPlace_ShouldReturnResponse() { + PlaceCommand.NearPlaceRequest mockCommand = mock(PlaceCommand.NearPlaceRequest.class); + Page mockResponse = mock(Page.class); + + when(placeService.getNearPlace(mockCommand)).thenReturn(mockResponse); + + var result = placeFacade.getNearPlace(mockCommand); + + assertEquals(result, mockResponse); + verify(placeService).getNearPlace(any( + kr.co.yigil.place.domain.PlaceCommand.NearPlaceRequest.class)); + } + + @DisplayName("getPlaceKeywords 메서드가 Response를 잘 반환하는지") + @Test + void getPlaceKeywords_ShouldReturnResponse() { + String keyword = "키워드"; + PlaceInfo.Keyword mockResponse = mock(PlaceInfo.Keyword.class); + + when(placeService.getPlaceKeywords(keyword)).thenReturn(List.of(mockResponse)); + + var result = placeFacade.getPlaceKeywords(keyword); + + assertEquals(result, List.of(mockResponse)); + verify(placeService).getPlaceKeywords(keyword); + } + + @DisplayName("searchPlace 메서드가 Response를 잘 반환하는지") + @Test + void searchPlace_ShouldReturnResponse() { + String keyword = "키워드"; + Pageable pageable = mock(Pageable.class); + Accessor accessor = mock(Accessor.class); + Main mockResponse = mock(Main.class); + Slice
mockSlice = mock(Slice.class); + + when(placeService.searchPlace(keyword, pageable, accessor)).thenReturn(mockSlice); + + var result = placeFacade.searchPlace(keyword, pageable, accessor); + + assertEquals(result, mockSlice); + verify(placeService).searchPlace(keyword, pageable, accessor); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/place/domain/PlaceServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/place/domain/PlaceServiceImplTest.java new file mode 100644 index 000000000..7138d9fdc --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/place/domain/PlaceServiceImplTest.java @@ -0,0 +1,243 @@ +package kr.co.yigil.place.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.bookmark.domain.BookmarkReader; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.place.domain.PlaceInfo.Main; +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.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.transaction.annotation.Transactional; + +@ExtendWith(MockitoExtension.class) +public class PlaceServiceImplTest { + + @Mock + private PlaceReader placeReader; + + @Mock + private PopularPlaceReader popularPlaceReader; + + @Mock + private PlaceCacheReader placeCacheReader; + + @Mock + private BookmarkReader bookmarkReader; + + @Mock + private MemberReader memberReader; + + @InjectMocks + private PlaceServiceImpl placeService; + + @DisplayName("getPopularPlace 메서드가 Info 객체의 List를 잘 반환하는지") + @Test + void getPopularPlace_ShouldReturnListOfInfo() { + Place mockPlace = mock(Place.class); + when(mockPlace.getId()).thenReturn(1L); + Accessor mockAccessor = mock(Accessor.class); + when(mockAccessor.isMember()).thenReturn(true); + when(mockAccessor.getMemberId()).thenReturn(1L); + when(placeCacheReader.getSpotCount(1L)).thenReturn(1); + when(bookmarkReader.isBookmarked(1L, 1L)).thenReturn(true); + when(popularPlaceReader.getPopularPlace()).thenReturn(List.of(mockPlace)); + + List popularPlace = placeService.getPopularPlace(mockAccessor); + + assertNotNull(popularPlace); + } + + @DisplayName("getPopularPlaceMore 메서드가 Info 객체의 List를 잘 반환하는지") + @Test + void getPopularPlaceMore_ShouldReturnListOfInfo() { + Place mockPlace = mock(Place.class); + when(mockPlace.getId()).thenReturn(1L); + Accessor mockAccessor = mock(Accessor.class); + when(mockAccessor.isMember()).thenReturn(true); + when(mockAccessor.getMemberId()).thenReturn(1L); + when(placeCacheReader.getSpotCount(1L)).thenReturn(1); + when(bookmarkReader.isBookmarked(1L, 1L)).thenReturn(true); + when(popularPlaceReader.getPopularPlaceMore()).thenReturn(List.of(mockPlace)); + + List popularPlaceMore = placeService.getPopularPlaceMore(mockAccessor); + + assertNotNull(popularPlaceMore); + } + + @DisplayName("getPopularPlaceByDemographics 메서드가 Info 객체의 List를 잘 반환하는지") + @Test + void getPopularPlaceByDemographics_ShouldReturnListOfInfo() { + Member member = mock(Member.class); + when(memberReader.getMember(anyLong())).thenReturn(member); + when(member.getAges()).thenReturn(Ages.FIFTIES); + when(member.getGender()).thenReturn(Gender.MALE); + + Place mockPlace = mock(Place.class); + List places = List.of(mockPlace); + when(placeReader.getPopularPlaceByDemographics(any(), any())).thenReturn(places); + when(mockPlace.getId()).thenReturn(1L); + when(placeCacheReader.getSpotCount(anyLong())).thenReturn(1); + when(bookmarkReader.isBookmarked(anyLong(), anyLong())).thenReturn(true); + + List popularPlaceByDemographics = placeService.getPopularPlaceByDemographics(1L); + + assertNotNull(popularPlaceByDemographics); + } + + @DisplayName("getPopularPlaceByDemographicsMore 메서드가 Info 객체의 List를 잘 반환하는지") + @Test + void getPopularPlaceByDemographicsMore_ShouldReturnListOfInfo() { + Member member = mock(Member.class); + when(memberReader.getMember(anyLong())).thenReturn(member); + when(member.getAges()).thenReturn(Ages.FIFTIES); + when(member.getGender()).thenReturn(Gender.MALE); + + Place mockPlace = mock(Place.class); + List places = List.of(mockPlace); + when(placeReader.getPopularPlaceByDemographicsMore(any(), any())).thenReturn(places); + when(mockPlace.getId()).thenReturn(1L); + when(placeCacheReader.getSpotCount(anyLong())).thenReturn(1); + when(bookmarkReader.isBookmarked(anyLong(), anyLong())).thenReturn(true); + + List popularPlaceByDemographicsMore = placeService.getPopularPlaceByDemographicsMore( + 1L); + + assertNotNull(popularPlaceByDemographicsMore); + } + + @DisplayName("getPlaceInRegion 메서드가 Info 객체의 List를 잘 반환하는지") + @Test + void getPlaceInRegion_ShouldReturnListOfInfo() { + Place mockPlace = mock(Place.class); + when(mockPlace.getId()).thenReturn(1L); + Accessor mockAccessor = mock(Accessor.class); + when(mockAccessor.isMember()).thenReturn(true); + when(mockAccessor.getMemberId()).thenReturn(1L); + when(placeCacheReader.getSpotCount(1L)).thenReturn(1); + when(bookmarkReader.isBookmarked(1L, 1L)).thenReturn(true); + when(placeReader.getPlaceInRegion(1L)).thenReturn(List.of(mockPlace)); + + List placeInRegion = placeService.getPlaceInRegion(1L, mockAccessor); + + assertNotNull(placeInRegion); + } + + @DisplayName("getPlaceInRegionMore 메서드가 Info 객체의 List를 잘 반환하는지") + @Test + void getPlaceInRegionMore_ShouldRetrunListOfInfo() { + Place mockPlace = mock(Place.class); + when(mockPlace.getId()).thenReturn(1L); + Accessor mockAccessor = mock(Accessor.class); + when(mockAccessor.isMember()).thenReturn(true); + when(mockAccessor.getMemberId()).thenReturn(1L); + when(placeCacheReader.getSpotCount(1L)).thenReturn(1); + when(bookmarkReader.isBookmarked(1L, 1L)).thenReturn(true); + when(placeReader.getPlaceInRegionMore(1L)).thenReturn(List.of(mockPlace)); + + List placeInRegionMore = placeService.getPlaceInRegionMore(1L, mockAccessor); + + assertNotNull(placeInRegionMore); + } + + @DisplayName("retrievePlace 메서드가 Detail 객체를 잘 반환하는지") + @Test + void retrievePlace_ShouldReturnDetail() { + Place mockPlace = mock(Place.class); + when(mockPlace.getId()).thenReturn(1L); + Accessor mockAccessor = mock(Accessor.class); + when(mockAccessor.isMember()).thenReturn(true); + when(mockAccessor.getMemberId()).thenReturn(1L); + when(placeCacheReader.getSpotCount(1L)).thenReturn(1); + when(bookmarkReader.isBookmarked(1L, 1L)).thenReturn(true); + when(placeReader.getPlace(1L)).thenReturn(mockPlace); + + PlaceInfo.Detail detail = placeService.retrievePlace(1L, mockAccessor); + + assertNotNull(detail); + } + + @DisplayName("retrievePlace 메서드가 Detail 객체를 잘 반환하는지") + @Test + void findPlace_ShouldReturnDetail() { + Place mockPlace = mock(Place.class); + when(mockPlace.getId()).thenReturn(1L); + Accessor mockAccessor = mock(Accessor.class); + when(mockAccessor.isMember()).thenReturn(true); + when(mockAccessor.getMemberId()).thenReturn(1L); + when(placeCacheReader.getSpotCount(1L)).thenReturn(1); + when(bookmarkReader.isBookmarked(1L, 1L)).thenReturn(true); + when(placeReader.getPlace(1L)).thenReturn(mockPlace); + + PlaceInfo.Detail detail = placeService.retrievePlace(1L, mockAccessor); + + assertNotNull(detail); + } + + @DisplayName("findPlaceStaticImage 메서드가 MapStaticImageInfo 객체를 잘 반환하는지") + @Test + void findPlaceStaticImage_ShouldReturnMapStaticImageInfo() { + Place mockPlace = mock(Place.class); + when(placeReader.findPlaceByNameAndAddress("장소", "장소구 장소면 장소리")).thenReturn(java.util.Optional.of(mockPlace)); + + PlaceInfo.MapStaticImageInfo mapStaticImageInfo = placeService.findPlaceStaticImage("장소", "장소구 장소면 장소리"); + + assertNotNull(mapStaticImageInfo); + } + + @DisplayName("getNearPlace 메서드가 Page 객체를 잘 반환하는지") + @Test + void getNearPlace_ShouldReturnPage() { + PlaceCommand.NearPlaceRequest mockCommand = mock(PlaceCommand.NearPlaceRequest.class); + when(placeReader.getNearPlace(mockCommand)).thenReturn(mock(org.springframework.data.domain.Page.class)); + + var result = placeService.getNearPlace(mockCommand); + + assertNotNull(result); + } + + @DisplayName("getPlaceKeywords 메서드가 Keyword 객체의 List를 잘 반환하는지") + @Test + void getPlaceKeywords_ShouldReturnListOfKeyword() { + when(placeReader.getPlaceKeywords("키워드")).thenReturn(List.of("키워드")); + var result = placeService.getPlaceKeywords("키워드"); + + assertNotNull(result); + } + + @DisplayName("searchPlace 메서드가 Main 객체의 Slice를 잘 반환하는지") + @Test + void searchPlace_ShouldReturnSlice() { + Place mockPlace = mock(Place.class); + when(mockPlace.getId()).thenReturn(1L); + Accessor mockAccessor = mock(Accessor.class); + when(mockAccessor.isMember()).thenReturn(true); + when(mockAccessor.getMemberId()).thenReturn(1L); + when(placeCacheReader.getSpotCount(1L)).thenReturn(1); + when(bookmarkReader.isBookmarked(1L, 1L)).thenReturn(true); + Slice mockSlice = new SliceImpl<>(List.of(mockPlace)); + when(placeReader.getPlacesByKeyword(anyString(), any())).thenReturn(mockSlice); + + var result = placeService.searchPlace("키워드", mock(Pageable.class), mockAccessor); + + assertNotNull(result); + } + +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceCacheReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceCacheReaderImplTest.java new file mode 100644 index 000000000..957faa00b --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceCacheReaderImplTest.java @@ -0,0 +1,34 @@ +package kr.co.yigil.place.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import kr.co.yigil.travel.domain.spot.SpotReader; +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; + +@ExtendWith(MockitoExtension.class) +public class PlaceCacheReaderImplTest { + + @Mock + private SpotReader spotReader; + + @InjectMocks + private PlaceCacheReaderImpl placeCacheReader; + + @DisplayName("getSpotCount 메서드가 spot의 Count를 잘 반환하는지") + @Test + void getSpotCount_ReturnsSpotCount() { + Long placeId = 1L; + int expectedSpotCount = 1; + when(spotReader.getSpotCountInPlace(placeId)).thenReturn(expectedSpotCount); + + int result = placeCacheReader.getSpotCount(placeId); + + assertEquals(expectedSpotCount, result); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceCacheStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceCacheStoreImplTest.java new file mode 100644 index 000000000..2c6a10d9d --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceCacheStoreImplTest.java @@ -0,0 +1,46 @@ +package kr.co.yigil.place.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import kr.co.yigil.place.domain.PlaceCacheReader; +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; + +@ExtendWith(MockitoExtension.class) +public class PlaceCacheStoreImplTest { + + @Mock + private PlaceCacheReader placeCacheReader; + + @InjectMocks + private PlaceCacheStoreImpl placeCacheStore; + + @DisplayName("incrementSpotCountInPlace 메서드가 spotCount를 잘 증가시키는지") + @Test + void incrementSpotCountInPlace_IncreasesSpotCount() { + Long placeId = 1L; + int spotCount = 1; + when(placeCacheReader.getSpotCount(placeId)).thenReturn(spotCount); + + int result = placeCacheStore.incrementSpotCountInPlace(placeId); + + assertEquals(spotCount + 1, result); + } + + @DisplayName("decrementSpotCountInPlace 메서드가 spotCount를 잘 감소시키는지") + @Test + void decrementSpotCountInPlace_DecreasesSpotCount() { + Long placeId = 1L; + int spotCount = 1; + when(placeCacheReader.getSpotCount(placeId)).thenReturn(spotCount); + + int result = placeCacheStore.decrementSpotCountInPlace(placeId); + + assertEquals(spotCount - 1, result); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceReaderImplTest.java new file mode 100644 index 000000000..5dadadf56 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceReaderImplTest.java @@ -0,0 +1,217 @@ +package kr.co.yigil.place.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import kr.co.yigil.place.domain.DemographicPlace; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCommand; +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.Pageable; +import org.springframework.data.domain.Slice; + +@ExtendWith(MockitoExtension.class) +public class PlaceReaderImplTest { + + @Mock + private PlaceRepository placeRepository; + + @Mock + private DemographicPlaceRepository demographicPlaceRepository; + + @InjectMocks + private PlaceReaderImpl placeReader; + + @DisplayName("findByNameAndAddress 메서드가 Place의 Optional 객체를 잘 반환하는지") + @Test + void findByNameAndAddress_ReturnsOptionalOfPlace() { + String placeName = "name"; + String address = "address"; + Place expectedPlace = mock(Place.class); + when(placeRepository.findByNameAndAddress(placeName, address)).thenReturn( + Optional.of(expectedPlace)); + + Optional result = placeReader.findPlaceByNameAndAddress(placeName, address); + + assert (result.isPresent()); + assertEquals(result.get(), expectedPlace); + } + + @DisplayName("getPlace 메서드가 Place을 잘 반환하는지") + @Test + void getPlace_ReturnsPlace() { + Long placeId = 1L; + Place expectedPlace = mock(Place.class); + when(placeRepository.findById(placeId)).thenReturn(Optional.of(expectedPlace)); + + Place result = placeReader.getPlace(placeId); + + assertEquals(expectedPlace, result); + } + + @DisplayName("getPlace 메서드가 placeId가 유효하지 않을 때 예외를 잘 발생시키는지") + @Test + void getPlace_ThrowsBadRequestException_WhenNotFound() { + Long placeId = 1L; + when(placeRepository.findById(placeId)).thenReturn(Optional.empty()); + + assertThrows(BadRequestException.class, () -> placeReader.getPlace(placeId)); + } + + @DisplayName("getPopularPlace 메서드가 Place의 리스트를 잘 반환하는지") + @Test + void getPopularPlace_ReturnsListOfPlace() { + Place place1 = mock(Place.class); + Place place2 = mock(Place.class); + Place place3 = mock(Place.class); + Place place4 = mock(Place.class); + Place place5 = mock(Place.class); + when(placeRepository.findTop5ByOrderByIdAsc()).thenReturn( + Arrays.asList(place1, place2, place3, place4, place5)); + + assertEquals(placeReader.getPopularPlace().size(), 5); + } + + @DisplayName("getPlaceInRegion 메서드가 Place의 리스트를 잘 반환하는지") + @Test + void getPlaceInRegion_ReturnsListOfPlace() { + Long regionId = 1L; + Place place1 = mock(Place.class); + Place place2 = mock(Place.class); + Place place3 = mock(Place.class); + Place place4 = mock(Place.class); + Place place5 = mock(Place.class); + when(placeRepository.findTop5ByRegionIdOrderByIdDesc(regionId)).thenReturn( + Arrays.asList(place1, place2, place3, place4, place5)); + + assertEquals(placeReader.getPlaceInRegion(regionId).size(), 5); + } + + @DisplayName("getPlaceInRegionMore 메서드가 Place의 리스트를 잘 반환하는지") + @Test + void getPlaceInRegionMore_ReturnsListOfPlace() { + Long regionId = 1L; + Place place1 = mock(Place.class); + Place place2 = mock(Place.class); + Place place3 = mock(Place.class); + Place place4 = mock(Place.class); + Place place5 = mock(Place.class); + Place place6 = mock(Place.class); + Place place7 = mock(Place.class); + Place place8 = mock(Place.class); + Place place9 = mock(Place.class); + Place place10 = mock(Place.class); + Place place11 = mock(Place.class); + Place place12 = mock(Place.class); + Place place13 = mock(Place.class); + Place place14 = mock(Place.class); + Place place15 = mock(Place.class); + Place place16 = mock(Place.class); + Place place17 = mock(Place.class); + Place place18 = mock(Place.class); + Place place19 = mock(Place.class); + Place place20 = mock(Place.class); + when(placeRepository.findTop20ByRegionIdOrderByIdDesc(regionId)).thenReturn( + Arrays.asList(place1, place2, place3, place4, place5, place6, place7, place8, place9, place10, + place11, place12, place13, place14, place15, place16, place17, place18, place19, place20)); + + assertEquals(placeReader.getPlaceInRegionMore(regionId).size(), 20); + } + + @DisplayName("getNearPlace 메서드가 Page를 잘 반환하는지") + @Test + void getNearPlace_ReturnsPageOfPlace() { + PlaceCommand.NearPlaceRequest command = mock(PlaceCommand.NearPlaceRequest.class); + when(command.getPageNo()).thenReturn(1); + PlaceCommand.Coordinate mockCoordinate = mock(PlaceCommand.Coordinate.class); + when(mockCoordinate.getX()).thenReturn(1.0); + when(mockCoordinate.getY()).thenReturn(1.0); + when(command.getMinCoordinate()).thenReturn(mockCoordinate); + when(command.getMaxCoordinate()).thenReturn(mockCoordinate); + + Page mockPage = mock(Page.class); + + when(placeRepository.findWithinCoordinates( + anyDouble(), anyDouble(), anyDouble(), anyDouble(), any(Pageable.class))).thenReturn(mockPage); + + var result = placeReader.getNearPlace(command); + + assertEquals(result, mockPage); + } + + @DisplayName("getPopularPlaceByDemographics 메서드가 Place의 리스트를 잘 반환하는지") + @Test + void getPopularPlaceByDemographics_ReturnsListOfPlace() { + DemographicPlace demographicPlace1 = mock(DemographicPlace.class); + Place place1 = mock(Place.class); + when(demographicPlace1.getPlace()).thenReturn(place1); + when(demographicPlaceRepository.findTop5ByAgesAndGenderOrderByReferenceCountDesc( + any(), any())).thenReturn(List.of(demographicPlace1)); + + List result = placeReader.getPopularPlaceByDemographics(Ages.TWENTIES, Gender.MALE); + + assertEquals(result.size(), 1); + assertTrue(result.containsAll(Arrays.asList(place1))); + + } + + @DisplayName("getPopularPlaceByDemographicsMore 메서드가 Place의 리스트를 잘 반환하는지") + @Test + void getPopularPlaceByDemographicsMore_ReturnsListOfPlace() { + DemographicPlace demographicPlace1 = mock(DemographicPlace.class); + Place place1 = mock(Place.class); + when(demographicPlace1.getPlace()).thenReturn(place1); + when(demographicPlaceRepository.findTop20ByAgesAndGenderOrderByReferenceCountDesc( + any(), any())).thenReturn(List.of(demographicPlace1)); + + List result = placeReader.getPopularPlaceByDemographicsMore(Ages.TWENTIES, + Gender.NONE); + + assertEquals(result.size(), 1); + assertTrue(result.containsAll(Arrays.asList(place1))); + } + + @DisplayName("getPlaceKeywords 메서드가 Place의 이름 리스트를 잘 반환하는지") + @Test + void getPlaceKeywords_ReturnsListOfString() { + String keyword = "keyword"; + Place place1 = mock(Place.class); + Place place2 = mock(Place.class); + when(placeRepository.findTop10ByNameStartingWith(keyword)).thenReturn( + Arrays.asList(place1, place2)); + + List result = placeReader.getPlaceKeywords(keyword); + + assertEquals(result.size(), 2); + } + + @DisplayName("getPlacesByKeyword 메서드가 Place의 Slice를 잘 반환하는지") + @Test + void getPlacesByKeyword_ReturnsSliceOfPlace() { + String keyword = "keyword"; + Pageable pageable = mock(Pageable.class); + Slice mockSlice = mock(Slice.class); + when(placeRepository.findByNameOrAddressContainingIgnoreCase(keyword, pageable)).thenReturn(mockSlice); + + var result = placeReader.getPlacesByKeyword(keyword, pageable); + + assertEquals(result, mockSlice); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceStoreImplTest.java new file mode 100644 index 000000000..eabe94a3e --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PlaceStoreImplTest.java @@ -0,0 +1,36 @@ +package kr.co.yigil.place.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.place.domain.Place; +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; + +@ExtendWith(MockitoExtension.class) +public class PlaceStoreImplTest { + + @Mock + private PlaceRepository placeRepository; + + @InjectMocks + private PlaceStoreImpl placeStore; + + @DisplayName("store가 저장된 Place을 잘 반환하는지") + @Test + void store_SavesAndReturnPlace() { + Place place = mock(Place.class); + when(placeRepository.save(place)).thenReturn(place); + + Place savedPlace = placeStore.store(place); + + assertEquals(place, savedPlace); + verify(placeRepository).save(place); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PopularPlaceReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PopularPlaceReaderImplTest.java new file mode 100644 index 000000000..b59924859 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/place/infrastructure/PopularPlaceReaderImplTest.java @@ -0,0 +1,52 @@ +package kr.co.yigil.place.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PopularPlace; +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; + +@ExtendWith(MockitoExtension.class) +public class PopularPlaceReaderImplTest { + + @Mock + private PopularPlaceRepository popularPlaceRepository; + + @InjectMocks + private PopularPlaceReaderImpl popularPlaceReader; + + @DisplayName("getPopularPlace 메서드가 Place의 List를 잘 반환하는지") + @Test + void getPopularPlace_ReturnsListOfPlace() { + PopularPlace popularPlace = mock(PopularPlace.class); + Place place = mock(Place.class); + when(popularPlace.getPlace()).thenReturn(place); + when(popularPlaceRepository.findTop5ByOrderByReferenceCountDesc()).thenReturn(List.of(popularPlace)); + + List result = popularPlaceReader.getPopularPlace(); + + assertEquals(result, List.of(place)); + } + + @DisplayName("getPopularPlaceMore 메서드가 Place의 List를 잘 반환하는지") + @Test + void getPopularPlaceMore_ReturnsListOfPlace() { + PopularPlace popularPlace = mock(PopularPlace.class); + Place place = mock(Place.class); + when(popularPlace.getPlace()).thenReturn(place); + when(popularPlaceRepository.findTop20ByOrderByReferenceCountDesc()).thenReturn(List.of(popularPlace)); + + List result = popularPlaceReader.getPopularPlaceMore(); + + assertEquals(result, List.of(place)); + } + +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/place/interfaces/controller/PlaceApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/place/interfaces/controller/PlaceApiControllerTest.java new file mode 100644 index 000000000..084ded10f --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/place/interfaces/controller/PlaceApiControllerTest.java @@ -0,0 +1,453 @@ +package kr.co.yigil.place.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +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.MockMvcResultMatchers.status; + +import java.util.List; +import kr.co.yigil.auth.Auth; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.SortBy; +import kr.co.yigil.global.SortOrder; +import kr.co.yigil.place.application.PlaceFacade; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCommand; +import kr.co.yigil.place.domain.PlaceCommand.NearPlaceRequest; +import kr.co.yigil.place.domain.PlaceInfo; +import kr.co.yigil.place.domain.PlaceInfo.Detail; +import kr.co.yigil.place.domain.PlaceInfo.Keyword; +import kr.co.yigil.place.domain.PlaceInfo.Main; +import kr.co.yigil.place.domain.PlaceInfo.MapStaticImageInfo; +import kr.co.yigil.place.interfaces.dto.PlaceCoordinateDto; +import kr.co.yigil.place.interfaces.dto.PlaceDetailInfoDto; +import kr.co.yigil.place.interfaces.dto.PlaceInfoDto; +import kr.co.yigil.place.interfaces.dto.mapper.PlaceMapper; +import kr.co.yigil.place.interfaces.dto.response.NearPlaceResponse; +import kr.co.yigil.place.interfaces.dto.response.PlaceKeywordResponse; +import kr.co.yigil.place.interfaces.dto.response.PlaceSearchResponse; +import kr.co.yigil.place.interfaces.dto.response.PlaceStaticImageResponse; +import kr.co.yigil.place.interfaces.dto.response.PopularPlaceResponse; +import kr.co.yigil.place.interfaces.dto.response.RegionPlaceResponse; +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.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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(PlaceApiController.class) +@AutoConfigureRestDocs +public class PlaceApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private PlaceFacade placeFacade; + + @MockBean + private PlaceMapper placeMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentationContextProvider) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentationContextProvider)).build(); + } + + @DisplayName("findPlaceStaticImage가 잘 동작하는지") + @Test + void findPlaceStaticImage_ShouldReturnOk() throws Exception { + PlaceInfo.MapStaticImageInfo mockInfo = mock(MapStaticImageInfo.class); + when(mockInfo.getImageUrl()).thenReturn("http://yigil.co.kr"); + when(mockInfo.isExists()).thenReturn(true); + PlaceStaticImageResponse mockResponse = new PlaceStaticImageResponse(true, "http://yigil.co.kr"); + + when(placeFacade.findPlaceStaticImage("어느장소", "어느주소")).thenReturn(mockInfo); + when(placeMapper.toPlaceStaticImageResponse(mockInfo)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/static-image") + .param("name", "어느장소") + .param("address", "어느주소")) + .andExpect(status().isOk()) + .andDo(document( + "places/find-static-image", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("name").description("찾고있는 장소의 이름"), + parameterWithName("address").description("찾고있는 장소의 주소") + ), + responseFields( + fieldWithPath("exists").type(JsonFieldType.BOOLEAN).description("해당 파일이 존재하는지 여부"), + fieldWithPath("map_static_image_url").type(JsonFieldType.STRING).description("찾은 파일의 이미지 Url") + ) + )); + + verify(placeFacade).findPlaceStaticImage("어느장소", "어느주소"); + } + + @DisplayName("getPopularPlace가 잘 동작하는지") + @Test + void getPopularPlace_ShouldReturnOk() throws Exception{ + Main placeInfo = mock(Main.class); + List
mockInfo = List.of(placeInfo); + PlaceInfoDto mockDto = new PlaceInfoDto(1L, "장소명", "10", "http://image.com", "3.5", true); + PopularPlaceResponse mockResponse = new PopularPlaceResponse(List.of(mockDto)); + + when(placeFacade.getPopularPlace(any(Accessor.class))).thenReturn(mockInfo); + when(placeMapper.toPopularPlaceResponse(mockInfo)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/popular")) + .andExpect(status().isOk()) + .andDo(document( + "places/get-popular-place", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + subsectionWithPath("places").description("place의 정보"), + fieldWithPath("places[].id").type(JsonFieldType.NUMBER).description("place의 고유 Id"), + fieldWithPath("places[].place_name").type(JsonFieldType.STRING).description("장소의 장소명"), + fieldWithPath("places[].review_count").type(JsonFieldType.STRING).description("리뷰의 개수"), + fieldWithPath("places[].thumbnail_image_url").type(JsonFieldType.STRING).description("장소의 대표 이미지의 Url"), + fieldWithPath("places[].rate").type(JsonFieldType.STRING).description("장소의 평점 정보"), + fieldWithPath("places[].bookmarked").type(JsonFieldType.BOOLEAN).description("해당 장소의 북마크 여부") + ) + )); + } + + @DisplayName("getPopularPlaceMore가 잘 동작하는지") + @Test + void getPopularPlaceMore_ShouldReturnOk() throws Exception { + Main placeInfo = mock(Main.class); + List
mockInfo = List.of(placeInfo); + PlaceInfoDto mockDto = new PlaceInfoDto(1L, "장소명", "10", "http://image.com", "3.5", true); + PopularPlaceResponse mockResponse = new PopularPlaceResponse(List.of(mockDto)); + + when(placeFacade.getPopularPlaceMore(any(Accessor.class))).thenReturn(mockInfo); + when(placeMapper.toPopularPlaceResponse(mockInfo)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/popular/more")) + .andExpect(status().isOk()) + .andDo(document( + "places/get-popular-place-more", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + subsectionWithPath("places").description("place의 정보"), + fieldWithPath("places[].id").type(JsonFieldType.NUMBER).description("place의 고유 Id"), + fieldWithPath("places[].place_name").type(JsonFieldType.STRING).description("장소의 장소명"), + fieldWithPath("places[].review_count").type(JsonFieldType.STRING).description("리뷰의 개수"), + fieldWithPath("places[].thumbnail_image_url").type(JsonFieldType.STRING).description("장소의 대표 이미지의 Url"), + fieldWithPath("places[].rate").type(JsonFieldType.STRING).description("장소의 평점 정보"), + fieldWithPath("places[].bookmarked").type(JsonFieldType.BOOLEAN).description("해당 장소의 북마크 여부") + ) + )); + + } + + @DisplayName("getPopularPlaceByDemographics가 잘 동작하는지") + @Test + void getPopularPlaceByDemographics_ShouldReturnOk() throws Exception { + Main placeInfo = mock(Main.class); + List
mockInfo = List.of(placeInfo); + PlaceInfoDto mockDto = new PlaceInfoDto(1L, "장소명", "10", "http://image.com", "3.5", true); + PopularPlaceResponse mockResponse = new PopularPlaceResponse(List.of(mockDto)); + + when(placeFacade.getPopularPlaceByDemographics(anyLong())).thenReturn(mockInfo); + when(placeMapper.toPopularPlaceResponse(mockInfo)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/popular-demographics")) + .andExpect(status().isOk()) + .andDo(document( + "places/get-popular-place-by-demographics", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + subsectionWithPath("places").description("place의 정보"), + fieldWithPath("places[].id").type(JsonFieldType.NUMBER).description("place의 고유 Id"), + fieldWithPath("places[].place_name").type(JsonFieldType.STRING).description("장소의 장소명"), + fieldWithPath("places[].review_count").type(JsonFieldType.STRING).description("리뷰의 개수"), + fieldWithPath("places[].thumbnail_image_url").type(JsonFieldType.STRING).description("장소의 대표 이미지의 Url"), + fieldWithPath("places[].rate").type(JsonFieldType.STRING).description("장소의 평점 정보"), + fieldWithPath("places[].bookmarked").type(JsonFieldType.BOOLEAN).description("해당 장소의 북마크 여부") + ) + )); + } + + @DisplayName("getPopularPlaceByDemographicsMore가 잘 동작하는지") + @Test + void getPopularPlaceByDemographicsMore_ShouldReturnOk() throws Exception { + Main placeInfo = mock(Main.class); + List
mockInfo = List.of(placeInfo); + PlaceInfoDto mockDto = new PlaceInfoDto(1L, "장소명", "10", "http://image.com", "3.5", true); + PopularPlaceResponse mockResponse = new PopularPlaceResponse(List.of(mockDto)); + + when(placeFacade.getPopularPlaceByDemographicsMore(anyLong())).thenReturn(mockInfo); + when(placeMapper.toPopularPlaceResponse(mockInfo)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/popular-demographics-more")) + .andExpect(status().isOk()) + .andDo(document( + "places/get-popular-place-by-demographics-more", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + subsectionWithPath("places").description("place의 정보"), + fieldWithPath("places[].id").type(JsonFieldType.NUMBER).description("place의 고유 Id"), + fieldWithPath("places[].place_name").type(JsonFieldType.STRING).description("장소의 장소명"), + fieldWithPath("places[].review_count").type(JsonFieldType.STRING).description("리뷰의 개수"), + fieldWithPath("places[].thumbnail_image_url").type(JsonFieldType.STRING).description("장소의 대표 이미지의 Url"), + fieldWithPath("places[].rate").type(JsonFieldType.STRING).description("장소의 평점 정보"), + fieldWithPath("places[].bookmarked").type(JsonFieldType.BOOLEAN).description("해당 장소의 북마크 여부") + ) + )); + } + + @DisplayName("retrievePlace 메서드가 잘 동작하는지") + @Test + void retrievePlace_ShouldReturnOk() throws Exception { + Long placeId = 1L; + Detail mockDetail = mock(Detail.class); + PlaceDetailInfoDto mockResponse = new PlaceDetailInfoDto(placeId, "장소명", "장소주소", "image.com", "image.net", true, 3.0, 50); + + when(placeFacade.retrievePlaceInfo(anyLong(), any(Accessor.class))).thenReturn(mockDetail); + when(placeMapper.toPlaceDetailInfoDto(mockDetail)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/{placeId}", placeId)) + .andExpect(status().isOk()) + .andDo(document( + "places/retrieve-place", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("placeId").description("장소의 고유 아이디") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("장소의 고유 아이디"), + fieldWithPath("place_name").type(JsonFieldType.STRING).description("장소의 장소명"), + fieldWithPath("address").type(JsonFieldType.STRING).description("장소의 주소"), + fieldWithPath("thumbnail_image_url").type(JsonFieldType.STRING).description("장소의 대표 이미지 URL"), + fieldWithPath("map_static_image_url").type(JsonFieldType.STRING).description("장소의 지도 이미지 URL"), + fieldWithPath("bookmarked").type(JsonFieldType.BOOLEAN).description("유저의 장소 북마크 여부"), + fieldWithPath("rate").type(JsonFieldType.NUMBER).description("장소의 평점"), + fieldWithPath("review_count").type(JsonFieldType.NUMBER).description("장소 내의 리뷰 개수") + ) + )); + } + + @DisplayName("getRegionPlaceMore 메서드가 잘 동작하는지") + @Test + void getRegionPlaceMore_ShouldReturnOk() throws Exception { + Main placeInfo = mock(Main.class); + List
mockInfo = List.of(placeInfo); + PlaceInfoDto mockDto = new PlaceInfoDto(1L, "장소명", "10", "http://image.com", "3.5", true); + RegionPlaceResponse mockResponse = new RegionPlaceResponse(List.of(mockDto)); + + when(placeFacade.getPlaceInRegionMore(anyLong(), any(Accessor.class))).thenReturn(mockInfo); + when(placeMapper.toRegionPlaceResponse(mockInfo)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/region/{regionId}/more", 1L)) + .andExpect(status().isOk()) + .andDo(document( + "places/get-region-place-more", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("regionId").description("지역의 고유 아이디") + ), + responseFields( + subsectionWithPath("places").description("place의 정보"), + fieldWithPath("places[].id").type(JsonFieldType.NUMBER).description("place의 고유 Id"), + fieldWithPath("places[].place_name").type(JsonFieldType.STRING).description("장소의 장소명"), + fieldWithPath("places[].review_count").type(JsonFieldType.STRING).description("리뷰의 개수"), + fieldWithPath("places[].thumbnail_image_url").type(JsonFieldType.STRING).description("장소의 대표 이미지의 Url"), + fieldWithPath("places[].rate").type(JsonFieldType.STRING).description("장소의 평점 정보"), + fieldWithPath("places[].bookmarked").type(JsonFieldType.BOOLEAN).description("해당 장소의 북마크 여부") + ) + )); + } + + @DisplayName("getRegionPlace 메서드가 잘 동작하는지") + @Test + void getRegionPlace_ShouldReturnOk() throws Exception { + Main placeInfo = mock(Main.class); + List
mockInfo = List.of(placeInfo); + PlaceInfoDto mockDto = new PlaceInfoDto(1L, "장소명", "10", "http://image.com", "3.5", true); + RegionPlaceResponse mockResponse = new RegionPlaceResponse(List.of(mockDto)); + + when(placeFacade.getPlaceInRegion(anyLong(), any(Accessor.class))).thenReturn(mockInfo); + when(placeMapper.toRegionPlaceResponse(mockInfo)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/region/{regionId}", 1L)) + .andExpect(status().isOk()) + .andDo(document( + "places/get-region-place", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("regionId").description("지역의 고유 아이디") + ), + responseFields( + subsectionWithPath("places").description("place의 정보"), + fieldWithPath("places[].id").type(JsonFieldType.NUMBER).description("place의 고유 Id"), + fieldWithPath("places[].place_name").type(JsonFieldType.STRING).description("장소의 장소명"), + fieldWithPath("places[].review_count").type(JsonFieldType.STRING).description("리뷰의 개수"), + fieldWithPath("places[].thumbnail_image_url").type(JsonFieldType.STRING).description("장소의 대표 이미지의 Url"), + fieldWithPath("places[].rate").type(JsonFieldType.STRING).description("장소의 평점 정보"), + fieldWithPath("places[].bookmarked").type(JsonFieldType.BOOLEAN).description("해당 장소의 북마크 여부") + ) + )); + } + + @DisplayName("getNearPlace 메서드가 잘 동작하는지") + @Test + void getNearPlace_ShouldReturnOk() throws Exception { + PlaceCommand.NearPlaceRequest mockRequest = mock(NearPlaceRequest.class); + when(placeMapper.toNearPlaceCommand(any( + kr.co.yigil.place.interfaces.dto.request.NearPlaceRequest.class))).thenReturn(mockRequest); + + Page mockSlice = mock(Page.class); + when(placeFacade.getNearPlace(mockRequest)).thenReturn(mockSlice); + + PlaceCoordinateDto mockDto = new PlaceCoordinateDto(1L, 127.0, 38.0, "장소명"); + NearPlaceResponse mockResponse = new NearPlaceResponse(List.of(mockDto), 1, 1); + + when(placeMapper.toNearPlaceResponse(mockSlice)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/near") + .param("minX", "1") + .param("minY", "1") + .param("maxX", "2") + .param("maxY", "2") + .param("page", "1")) + .andExpect(status().isOk()) + .andDo(document( + "places/get-near-place", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("minX").description("최소 x 좌표"), + parameterWithName("minY").description("최소 y 좌표"), + parameterWithName("maxX").description("최대 x 좌표"), + parameterWithName("maxY").description("최대 y 좌표"), + parameterWithName("page").description("페이지 번호") + ), + responseFields( + subsectionWithPath("places").description("주변 장소의 정보"), + fieldWithPath("places[].id").type(JsonFieldType.NUMBER).description("장소의 고유 아이디"), + fieldWithPath("places[].place_name").type(JsonFieldType.STRING).description("장소의 이름"), + fieldWithPath("places[].x").type(JsonFieldType.NUMBER).description("장소의 x 좌표"), + fieldWithPath("places[].y").type(JsonFieldType.NUMBER).description("장소의 y 좌표"), + fieldWithPath("current_page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("total_pages").type(JsonFieldType.NUMBER).description("총 페이지의 개수") + ) + )); + } + + @DisplayName("getPlaceKeyword 메서드가 잘 동작하는지") + @Test + void getPlaceKeyword_ShouldReturnOk() throws Exception { + String keyword = "키워드"; + Keyword mockKeyword = mock(Keyword.class); + when(placeFacade.getPlaceKeywords(keyword)).thenReturn(List.of(mockKeyword)); + + PlaceKeywordResponse mockResponse = new PlaceKeywordResponse(List.of("키워드")); + when(placeMapper.toPlaceKeywordResponse(List.of(mockKeyword))).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/keyword") + .param("keyword", keyword)) + .andExpect(status().isOk()) + .andDo(document( + "places/get-place-keyword", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("keyword").description("검색하고자 하는 키워드") + ), + responseFields( + fieldWithPath("keywords[]").type(JsonFieldType.ARRAY).description("추천 키워드의 이름") + ) + )); + } + + @DisplayName("searchPlace 메서드가 잘 동작하는지") + @Test + void searchPlace_ShouldReturnOk() throws Exception { + Pageable pageable = PageRequest.of(0, 5); + Accessor accessor = mock(Accessor.class); + Slice
mockSlice = mock(Slice.class); + when(placeFacade.searchPlace("키워드", pageable, accessor)).thenReturn(mockSlice); + + PlaceInfoDto mockDto = new PlaceInfoDto(1L, "장소명", "10", "http://image.com", "3.5", true); + PlaceSearchResponse mockResponse = new PlaceSearchResponse(List.of(mockDto), true); + + when(placeFacade.searchPlace(anyString(), any(Pageable.class), any(Accessor.class))).thenReturn(mockSlice); + when(placeMapper.toPlaceSearchResponse(mockSlice)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/places/search") + .param("keyword", "키워드") + .param("page", "1") + .param("size", "5") + .param("sortBy", "latest_uploaded_time") + .param("sortOrder", "desc")) + .andExpect(status().isOk()) + .andDo(document( + "places/search-place", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("keyword").description("검색하고자 하는 키워드"), + parameterWithName("page").description("현재 페이지 - default:1").optional(), + parameterWithName("size").description("페이지 크기 - default:5").optional(), + parameterWithName("sortBy").description("정렬 옵션 - latest_uploaded_time(디폴트값) / rate") + .optional(), + parameterWithName("sortOrder").description("정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순") + .optional() + ), + responseFields( + fieldWithPath("has_next").type(JsonFieldType.BOOLEAN).description("다음 페이지가 있는지 여부"), + subsectionWithPath("places").description("place의 정보"), + fieldWithPath("places[].id").type(JsonFieldType.NUMBER).description("place의 고유 Id"), + fieldWithPath("places[].place_name").type(JsonFieldType.STRING).description("장소의 장소명"), + fieldWithPath("places[].review_count").type(JsonFieldType.STRING).description("리뷰의 개수"), + fieldWithPath("places[].thumbnail_image_url").type(JsonFieldType.STRING).description("장소의 대표 이미지의 Url"), + fieldWithPath("places[].rate").type(JsonFieldType.STRING).description("장소의 평점 정보"), + fieldWithPath("places[].bookmarked").type(JsonFieldType.BOOLEAN).description("해당 장소의 북마크 여부") + ) + )); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/post/application/PostServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/post/application/PostServiceTest.java deleted file mode 100644 index 3f670aaf8..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/post/application/PostServiceTest.java +++ /dev/null @@ -1,232 +0,0 @@ -package kr.co.yigil.post.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.util.List; -import java.util.Optional; -import kr.co.yigil.comment.application.CommentRedisIntegrityService; -import kr.co.yigil.comment.domain.CommentCount; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.post.domain.repository.PostRepository; -import kr.co.yigil.post.dto.response.PostDeleteResponse; -import kr.co.yigil.post.dto.response.PostListResponse; -import kr.co.yigil.post.dto.response.PostResponse; -import kr.co.yigil.travel.domain.Course; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.domain.Travel; -import kr.co.yigil.travel.domain.repository.TravelRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.LineString; -import org.locationtech.jts.geom.Point; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class PostServiceTest { - - @Spy - @InjectMocks - private PostService postService; - - @Mock - private PostRepository postRepository; - - @Mock - private TravelRepository travelRepository; - @Mock - private CommentRedisIntegrityService commentRedisIntegrityService; - - @DisplayName("When finding all posts, then return PostListResponse") - @Test - void whenFindAllPosts_thenReturnPostListResponse() { - - // Given - GeometryFactory geometryFactory = new GeometryFactory(); - - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Point mockPoint2 = geometryFactory.createPoint(new Coordinate(1,1)); - Point mockPoint3 = geometryFactory.createPoint(new Coordinate(2,2)); - Point mockPoint4 = geometryFactory.createPoint(new Coordinate(3,3)); - - Spot mockSpot1 = new Spot(mockPoint1,"spot title1", "spot file url1","spot description1"); - Spot mockSpot2 = new Spot(mockPoint2,"spot title2", "spot file url2","spot description2"); - Spot mockSpot3 = new Spot(mockPoint3,"spot title3", "spot file url3","spot description3"); - Spot mockSpot4 = new Spot(mockPoint4,"spot title4", "spot file url4","spot description4"); - - List coordinates = List.of( - new Coordinate(2, 2), - new Coordinate(3, 3) - ); - - LineString lineString = geometryFactory.createLineString(coordinates.toArray(new Coordinate[0])); - Course mockCourse1 = new Course(lineString, List.of(mockSpot3, mockSpot4), 1, "coursetitle"); - - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - - Post mockPost1 = new Post(1L, mockSpot1, mockMember); - Post mockPost2 = new Post(2L, mockSpot2, mockMember); - Post mockPost3 = new Post(3L, mockCourse1, mockMember); - - List posts = List.of(mockPost1, mockPost2, mockPost3); - when(postRepository.findAll()).thenReturn(posts); - when(commentRedisIntegrityService.ensureCommentCount(any())).thenReturn(new CommentCount(1L, 1)); - - PostResponse mockPostResponse1 = PostResponse.from(mockSpot1, mockPost1, 1); - PostResponse mockPostResponse2 = PostResponse.from(mockSpot2, mockPost2, 1); - PostResponse mockPostResponse3 = PostResponse.from(mockCourse1, mockPost3, 1); - - List postResponses = List.of(mockPostResponse1, mockPostResponse2, mockPostResponse3); - PostListResponse mockPostListResponse = PostListResponse.from(postResponses); - - // When - PostListResponse postListResponse = postService.findAllPosts(); - // Then - assertNotNull(postListResponse); - assertThat(postService.findAllPosts()).isInstanceOf(PostListResponse.class); - assertThat(postService.findAllPosts()).isEqualTo(mockPostListResponse); - assertEquals(posts.size(), postListResponse.getPosts().size()); - - } - - @DisplayName("When finding post by ID, then return Post") - @Test - void whenFindPostById_thenReturnPost() { - // Given - Long postId = 1L; - - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - Travel mockTravel = new Travel(1L); - Post mockPost = new Post(mockTravel ,mockMember); - when(postRepository.findById(anyLong())).thenReturn(java.util.Optional.of(mockPost)); - - // When - Post resultPost = postService.findPostById(postId); - - // Then - assertNotNull(resultPost); - assertEquals(mockPost, resultPost); - } - - @DisplayName("When finding post by ID, then return Post") - @Test - void GivenInvalidPostId_whenFindPostById_thenThrowException() { - // Given - - // When - when(postRepository.findById(anyLong())).thenThrow(BadRequestException.class); - - // Then - assertThrows(BadRequestException.class, ()-> postService.findPostById(anyLong())); - } - - @DisplayName("When creating post, then invoke save method") - @Test - void whenCreatePost_thenInvokeSaveMethod() { - // Given - - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - Travel mockTravel = new Travel(1L); - - // When - assertDoesNotThrow(() -> postService.createPost(mockTravel, mockMember)); - - // Then: Verify that the save method was called - verify(postRepository, times(1)).save(any()); - } - @DisplayName("When updating post, then invoke save method") - @Test - void whenUpdatePost_thenInvokeSaveMethod() { - // Given - Long postId = 1L; - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - Travel mockTravel = new Travel(1L); - - // When - assertDoesNotThrow(() -> postService.updatePost(postId, mockTravel, mockMember)); - - // Then: Verify that the save method was called - verify(postRepository, times(1)).save(any()); - } - - @DisplayName("When deleting post, then invoke delete methods and return PostDeleteResponse") - @Test - void whenDeletePost_thenInvokeDeleteMethodsAndReturnPostDeleteResponse() { - // Given - Long memberId = 1L; - Long postId = 1L; - Travel mockTravel = new Travel(1L); - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - Post mockPost = new Post(postId, mockTravel, mockMember); - - when(postRepository.findById(postId)).thenReturn(java.util.Optional.of(mockPost)); - doNothing().when(postService).validatePostWriter(memberId, postId); - doNothing().when(travelRepository).delete(any()); - doNothing().when(postRepository).delete(any()); - - // When - PostDeleteResponse postDeleteResponse = postService.deletePost(memberId, postId); - - // Then: Verify that the delete methods were called - verify(travelRepository, times(1)).delete(any()); - verify(postRepository, times(1)).delete(any()); - assertNotNull(postDeleteResponse); - assertEquals("post 삭제 성공", postDeleteResponse.getMessage()); - } - - - @DisplayName("When deleting only post, then invoke deleteByTravelIdAndMemberId method") - @Test - void whenDeleteOnlyPost_thenInvokeDeleteByTravelIdAndMemberIdMethod() { - // Given - Long memberId = 1L; - Long travelId = 1L; - Long postId = 1L; - Travel mockTravel = new Travel(1L); - Member mockMember = new Member(1L, "kiit0901@gmail.com", "123456", "stone", "profile.jpg", SocialLoginType.KAKAO); - Post mockPost = new Post(postId, mockTravel, mockMember); - - when(postRepository.findByMemberIdAndTravelIdAndIsDeleted(memberId, travelId, false)).thenReturn( - Optional.of(mockPost)); - // When - assertDoesNotThrow(() -> postService.deleteOnlyPost(memberId, travelId)); - - // Then: Verify that deleteByTravelIdAndMemberId method was called - verify(postRepository, times(1)).findByMemberIdAndTravelIdAndIsDeleted(anyLong(), anyLong(), anyBoolean()); - } - - @DisplayName("When validating post writer with invalid authority, then throw BadRequestException") - @Test - void whenValidatePostWriterWithInvalidAuthority_thenThrowBadRequestException() { - // Given - Long invalidMemberId = 1L; - Long postId = 1L; - when(postRepository.existsByMemberIdAndId(invalidMemberId, postId)).thenReturn(false); - - // When, Then - assertThrows(BadRequestException.class, () -> postService.validatePostWriter(invalidMemberId, postId)); - } - - @DisplayName("When validating post writer with valid authority, then do nothing") - @Test - void whenValidatePostWriterWithValidAuthority_thenDoNothing() { - // Given - Long validMemberId = 1L; - Long postId = 1L; - when(postRepository.existsByMemberIdAndId(validMemberId, postId)).thenReturn(true); - - // When, Then: No exception should be thrown - assertDoesNotThrow(() -> postService.validatePostWriter(validMemberId, postId)); - } - -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/post/presentation/PostControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/post/presentation/PostControllerTest.java deleted file mode 100644 index 4136ebcb6..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/post/presentation/PostControllerTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package kr.co.yigil.post.presentation; - -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.util.Collections; - -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.post.application.PostService; -import kr.co.yigil.post.dto.response.PostDeleteResponse; -import kr.co.yigil.post.dto.response.PostListResponse; -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.junit.jupiter.MockitoExtension; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@ExtendWith(MockitoExtension.class) -@WebMvcTest(PostController.class) -class PostControllerTest { - - private MockMvc mockMvc; - - @MockBean - private PostService postService; - - @InjectMocks - private PostController postController; - - @BeforeEach - public void setup(WebApplicationContext webApplicationContext) { - this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } - - @DisplayName("Find All Posts - Success") - @Test - void whenFindAllPosts_thenReturnPostListResponse() throws Exception { - PostListResponse mockResponse = new PostListResponse(Collections.emptyList()); - given(postService.findAllPosts()).willReturn(mockResponse); - - mockMvc.perform(get("/api/v1/posts") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @DisplayName("Delete Post - Success") - @Test - void whenDeletePost_thenReturnPostDeleteResponse() throws Exception { - PostDeleteResponse mockResponse = new PostDeleteResponse("Post deleted successfully"); - Accessor accessor = Accessor.member(1L); - - given(postService.deletePost(anyLong(), anyLong())).willReturn(mockResponse); - - mockMvc.perform(delete("/api/v1/posts/1") - .contentType(MediaType.APPLICATION_JSON) - .sessionAttr("memberId", accessor.getMemberId())) - .andExpect(status().isOk()); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/region/application/RegionFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/region/application/RegionFacadeTest.java new file mode 100644 index 000000000..0feab8cd5 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/region/application/RegionFacadeTest.java @@ -0,0 +1,54 @@ +package kr.co.yigil.region.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.region.domain.RegionInfo.Category; +import kr.co.yigil.region.domain.RegionInfo.Main; +import kr.co.yigil.region.domain.RegionService; +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; + +@ExtendWith(MockitoExtension.class) +public class RegionFacadeTest { + + @Mock + private RegionService regionService; + + @InjectMocks + private RegionFacade regionFacade; + + @DisplayName("getRegionSelectList 메서드가 Category Info를 잘 반환하는지") + @Test + void getRegionSelectList_ShouldReturnResponse() { + Category mockCategory = mock(Category.class); + + when(regionService.getAllRegionCategory(1L)).thenReturn(List.of(mockCategory)); + + var result = regionFacade.getRegionSelectList(1L); + + assertEquals(result, List.of(mockCategory)); + verify(regionService).getAllRegionCategory(1L); + } + + @DisplayName("getMyRegions 메서드가 응답을 잘 반환하는지") + @Test + void getMyRegions_ShouldReturnResponse() { + Main mockMain = mock(Main.class); + + when(regionService.getMyRegions(1L)).thenReturn(List.of(mockMain)); + + var result = regionFacade.getMyRegions(1L); + + assertEquals(result, List.of(mockMain)); + verify(regionService).getMyRegions(1L); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/region/domain/RegionServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/region/domain/RegionServiceImplTest.java new file mode 100644 index 000000000..c05a15a1a --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/region/domain/RegionServiceImplTest.java @@ -0,0 +1,50 @@ +package kr.co.yigil.region.domain; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +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; + +@ExtendWith(MockitoExtension.class) +public class RegionServiceImplTest { + + @Mock + private RegionCategoryReader regionCategoryReader; + + @Mock + private MemberReader memberReader; + + @InjectMocks + private RegionServiceImpl regionService; + + @DisplayName("getAllRegionCategory 메서드가 Category Info를 잘 반환하는지") + @Test + void getAllRegionCategory_ShouldReturnResponse() { + Member mockMember = mock(Member.class); + when(memberReader.getMember(anyLong())).thenReturn(mockMember); + RegionCategory mockCategory = mock(RegionCategory.class); + when(regionCategoryReader.getAllRegionCategory()).thenReturn(List.of(mockCategory)); + + var result = regionService.getAllRegionCategory(1L); + assertNotNull(result); + } + + @DisplayName("getMyRegions 메서드가 Main Info를 잘 반환하는지") + @Test + void getMyRegions_ShouldReturnResponse() { + Member mockMember = mock(Member.class); + when(memberReader.getMember(anyLong())).thenReturn(mockMember); + var result = regionService.getMyRegions(1L); + assertNotNull(result); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/region/infrastructure/RegionCategoryReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/region/infrastructure/RegionCategoryReaderImplTest.java new file mode 100644 index 000000000..9f8f67b44 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/region/infrastructure/RegionCategoryReaderImplTest.java @@ -0,0 +1,31 @@ +package kr.co.yigil.region.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.region.domain.RegionCategory; +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; + +@ExtendWith(MockitoExtension.class) +public class RegionCategoryReaderImplTest { + + @Mock + private RegionCategoryRepository regionCategoryRepository; + + @InjectMocks + private RegionCategoryReaderImpl regionCategoryReader; + + @DisplayName("getAllRegionCategory 메서드가 RegionCategory를 잘 반환하는지") + @Test + void getAllRegionCategory_ShouldReturnRegionCategory() { + RegionCategory mockRegionCategory = new RegionCategory(); + when(regionCategoryRepository.findAll()).thenReturn(List.of(mockRegionCategory)); + assertEquals(regionCategoryReader.getAllRegionCategory(), List.of(mockRegionCategory)); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/region/infrastructure/RegionReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/region/infrastructure/RegionReaderImplTest.java new file mode 100644 index 000000000..a8a393dd8 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/region/infrastructure/RegionReaderImplTest.java @@ -0,0 +1,47 @@ +package kr.co.yigil.region.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.region.domain.Region; +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; + +@ExtendWith(MockitoExtension.class) +public class RegionReaderImplTest { + + @Mock + private RegionRepository regionRepository; + + @InjectMocks + private RegionReaderImpl regionReader; + + @DisplayName("getRegions 메서드가 id 리스트에 해당하는 Region을 잘 반환하는지") + @Test + void getRegions_ReturnsRegions() { + Long regionId = 1L; + Region expectedRegion = mock(Region.class); + when(regionRepository.findById(regionId)).thenReturn(Optional.of(expectedRegion)); + + var result = regionReader.getRegions(List.of(1L)); + result.getFirst().equals(expectedRegion); + } + + @DisplayName("getRegions 메서드가 존재하지 않는 id에 대한 요청에 예외를 잘 발생시키는지") + @Test + void getRegions_ThrowsBadRequestException_WhenNotFound() { + Long regionId = 1L; + when(regionRepository.findById(regionId)).thenReturn(Optional.empty()); + assertThrows(BadRequestException.class, () -> regionReader.getRegions(List.of(1L))); + } + + +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/region/interfaces/controller/RegionApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/region/interfaces/controller/RegionApiControllerTest.java new file mode 100644 index 000000000..4169e60f0 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/region/interfaces/controller/RegionApiControllerTest.java @@ -0,0 +1,116 @@ +package kr.co.yigil.region.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +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.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import kr.co.yigil.region.application.RegionFacade; +import kr.co.yigil.region.domain.RegionInfo.Category; +import kr.co.yigil.region.domain.RegionInfo.Main; +import kr.co.yigil.region.interfaces.dto.MyRegionDto; +import kr.co.yigil.region.interfaces.dto.RegionCategoryDto; +import kr.co.yigil.region.interfaces.dto.RegionDto; +import kr.co.yigil.region.interfaces.dto.mapper.RegionMapper; +import kr.co.yigil.region.interfaces.dto.response.MyRegionResponse; +import kr.co.yigil.region.interfaces.dto.response.RegionSelectResponse; +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.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.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(RegionApiController.class) +@AutoConfigureRestDocs +public class RegionApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private RegionFacade regionFacade; + + @MockBean + private RegionMapper regionMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentationContextProvider) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentationContextProvider)).build(); + } + + @DisplayName("getRegionSelect 메서드가 잘 동작하는지") + @Test + void getRegionSelect_ReturnsOk() throws Exception { + RegionDto mockRegion = new RegionDto(1L, "홍대 | 와플", true); + RegionCategoryDto mockCategory = new RegionCategoryDto("서울 북부", List.of(mockRegion)); + RegionSelectResponse mockResponse = new RegionSelectResponse(List.of(mockCategory)); + + Category mockInfo = mock(Category.class); + when(regionFacade.getRegionSelectList(anyLong())).thenReturn(List.of(mockInfo)); + when(regionMapper.toRegionSelectResponse(List.of(mockInfo))).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/regions/select") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document( + "regions/region-select-form", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + subsectionWithPath("categories").description("지역별 카테고리 정보"), + fieldWithPath("categories[].category_name").type(JsonFieldType.STRING).description("지역별 카테고리 명"), + subsectionWithPath("categories[].regions").description("카테고리 내 지역 정보"), + fieldWithPath("categories[].regions[].id").type(JsonFieldType.NUMBER).description("지역의 고유 id"), + fieldWithPath("categories[].regions[].region_name").type(JsonFieldType.STRING).description("지역명"), + fieldWithPath("categories[].regions[].selected").type(JsonFieldType.BOOLEAN).description("사용자의 해당 지역의 관심지역 설정 여부") + ) + )); + } + + @DisplayName("getMyRegion 메서드가 잘 동작하는지") + @Test + void getMyRegion_ReturnsOk() throws Exception { + MyRegionDto mockRegion = new MyRegionDto(1L, "홍대 | 상수"); + MyRegionResponse mockResponse = new MyRegionResponse(List.of(mockRegion)); + + Main mockInfo = mock(Main.class); + when(regionMapper.toMyRegionResponse(List.of(mockInfo))).thenReturn(mockResponse); + + when(regionFacade.getMyRegions(anyLong())).thenReturn(List.of(mockInfo)); + + mockMvc.perform(get("/api/v1/regions/my") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document( + "regions/my-region", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + subsectionWithPath("regions").description("지역 정보"), + fieldWithPath("regions[].id").type(JsonFieldType.NUMBER).description("지역의 고유 Id"), + fieldWithPath("regions[].name").type(JsonFieldType.STRING).description("지역의 이름") + ) + )); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/CourseFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/CourseFacadeTest.java new file mode 100644 index 000000000..9a6442cc3 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/CourseFacadeTest.java @@ -0,0 +1,219 @@ +package kr.co.yigil.travel.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.FileType; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.global.Selected; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.course.CourseCommand.ModifyCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequestWithSpotInfo; +import kr.co.yigil.travel.domain.course.CourseInfo; +import kr.co.yigil.travel.domain.course.CourseService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.locationtech.jts.geom.LineString; +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.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +@ExtendWith(MockitoExtension.class) +public class CourseFacadeTest { + + @InjectMocks + private CourseFacade courseFacade; + + @Mock + private CourseService courseService; + + @Mock + private FileUploader fileUploader; + + + @DisplayName("getCourseSliceInPlace 메서드가 유효한 요청이 들어왔을 때 Course의 Slice객체를 잘 반환하는지") + @Test + void whenGetCoursesSliceInPlace_WithValidRequest() { + Long id = 1L; + String email = "test@test.com"; + String socialLoginId = "12345"; + String nickname = "tester"; + String profileImageUrl = "test.jpg"; + Member member = new Member(id, email, socialLoginId, nickname, profileImageUrl, + SocialLoginType.KAKAO, Ages.NONE, Gender.NONE); + + String title = "Test Course Title"; + String description = "Test Course Description"; + double rate = 5.0; + LineString path = null; + boolean isPrivate = false; + List spots = Collections.emptyList(); + int representativeSpotOrder = 0; + AttachFile mapStaticImageFile = null; + + Course course = new Course(id, member, title, description, rate, path, isPrivate, spots, + representativeSpotOrder, mapStaticImageFile); + + Long placeId = 1L; + Pageable pageable = PageRequest.of(0, 10); + Slice expectedSlice = new PageImpl<>(Collections.singletonList(course), pageable, + 1); + when(courseService.getCoursesSliceInPlace(eq(placeId), any(Pageable.class))).thenReturn( + expectedSlice); + + Slice result = courseFacade.getCourseSliceInPlace(placeId, pageable); + + assertNotNull(result); + assertEquals(expectedSlice, result); + verify(courseService, times(1)).getCoursesSliceInPlace(eq(placeId), any(Pageable.class)); + } + + @DisplayName("registerCourse 메서드가 CourseServicer를 잘 호출하는지") + @Test + void registerCourse_ShouldCallService() { + RegisterCourseRequest command = mock(RegisterCourseRequest.class); + Long memberId = 1L; + + courseFacade.registerCourse(command, memberId); + + verify(courseService).registerCourse(command, memberId); + } + + @DisplayName("registerCourseWithoutSeries 메서드가 CourseService를 잘 호출하는지") + @Test + void registerCourseWithoutSeries_ShouldCallServiceAndUploader() { + RegisterCourseRequestWithSpotInfo command = mock(RegisterCourseRequestWithSpotInfo.class); + Long memberId = 1L; + + courseFacade.registerCourseWithoutSeries(command, memberId); + + verify(courseService).registerCourseWithoutSeries(command, memberId); + } + + @DisplayName("retrieveCourseInfo 메서드가 CourseInfo를 잘 반환하는지") + @Test + void retrieveCourseInfo_ShoudReturnCourseInfo() { + Long courseId = 1L; + CourseInfo.Main expectedCourseInfo = mock(CourseInfo.Main.class); + + when(courseService.retrieveCourseInfo(courseId)).thenReturn(expectedCourseInfo); + + CourseInfo.Main result = courseFacade.retrieveCourseInfo(courseId); + + assertEquals(expectedCourseInfo, result); + verify(courseService).retrieveCourseInfo(courseId); + } + + @DisplayName("modifyCourse 메서드가 CourseService를 잘 호출하는지") + @Test + void modifyCourse_ShouldCallService() { + ModifyCourseRequest command = mock(ModifyCourseRequest.class); + Long courseId = 1L; + Long memberId = 1L; + Course mockCourse = mock(Course.class); + when(courseService.modifyCourse(command, courseId, memberId)).thenReturn(mockCourse); + + courseFacade.modifyCourse(command, courseId, memberId); + + verify(courseService).modifyCourse(command, courseId, memberId); + } + + @DisplayName("deleteCourse 메서드가 CourseSerivce를 잘 호출하는지") + @Test + void deleteCourse_ShouldCallService() { + Long courseId = 1L; + Long memberId = 1L; + + doNothing().when(courseService).deleteCourse(courseId, memberId); + + courseFacade.deleteCourse(courseId, memberId); + + verify(courseService).deleteCourse(courseId, memberId); + } + + + @DisplayName("getMemberCourseInfo 메서드가 유효한 요청이 들어왔을 때 CourseInfo의 MyCoursesResponse 객체를 잘 반환하는지") + @Test + void WhenGetMemberCourseInfo_ThenShouldReturnValidMyCoursesResponse() { + // Given + Long memberId = 1L; + int totalPages = 1; + PageRequest pageable = PageRequest.of(0, 5); + + String email = "test@test.com"; + String socialLoginId = "12345"; + String nickname = "tester"; + String profileImageUrl = "test.jpg"; + Member member = new Member(memberId, email, socialLoginId, nickname, profileImageUrl, + SocialLoginType.KAKAO, Ages.NONE, Gender.NONE); + + Long courseId = 1L; + String title = "Test Course Title"; + double rate = 5.0; + LineString path = null; + boolean isPrivate = false; + List spots = Collections.emptyList(); + int representativeSpotOrder = 0; + AttachFile mapStaticImageFile = new AttachFile(FileType.IMAGE, "test.jpg", "test.jpg", 10L); + + Course mockCourse = new Course(courseId, member, title, null, rate, path, isPrivate, + spots, representativeSpotOrder, mapStaticImageFile); + + CourseInfo.CourseListInfo courseInfo = new CourseInfo.CourseListInfo(mockCourse); + List courseList = Collections.singletonList(courseInfo); + + CourseInfo.MyCoursesResponse mockCourseListResponse = new CourseInfo.MyCoursesResponse( + courseList, + totalPages + ); + + when(courseService.retrieveCourseList(anyLong(), any(Pageable.class), + any(Selected.class))).thenReturn( + mockCourseListResponse); + + // When + var result = courseFacade.getMemberCoursesInfo(memberId, pageable, Selected.ALL); + + // Then + assertThat(result).isNotNull() + .isInstanceOf(CourseInfo.MyCoursesResponse.class) + .usingRecursiveComparison().isEqualTo(mockCourseListResponse); + assertThat(result.getContent().size()).isEqualTo(1); + } + + @DisplayName("searchCourseByPlaceName 메서드가 유효한 요청이 들어왔을 때 CourseInfo의 Slice 객체를 잘 반환하는지") + @Test + void WhenSearchCourseByPlaceName_ThenShouldReturnValidSlice() { + CourseInfo.Slice mockSlice = mock(CourseInfo.Slice.class); + when(courseService.searchCourseByPlaceName(anyString(), any(Accessor.class), any(Pageable.class))).thenReturn(mockSlice); + + var result = courseFacade.searchCourseByPlaceName("test", mock(Accessor.class), PageRequest.of(0, 5)); + + assertThat(result).isNotNull(); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/CourseServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/CourseServiceTest.java deleted file mode 100644 index 70ab8b8e5..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/CourseServiceTest.java +++ /dev/null @@ -1,202 +0,0 @@ -package kr.co.yigil.travel.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.when; - -import java.util.Arrays; -import java.util.List; -import kr.co.yigil.comment.application.CommentService; -import kr.co.yigil.member.application.MemberService; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.post.application.PostService; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.travel.domain.Course; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.domain.Travel; -import kr.co.yigil.travel.domain.repository.CourseRepository; -import kr.co.yigil.travel.domain.repository.TravelRepository; -import kr.co.yigil.travel.dto.request.CourseCreateRequest; -import kr.co.yigil.travel.dto.request.CourseUpdateRequest; -import kr.co.yigil.travel.dto.response.CourseCreateResponse; -import kr.co.yigil.travel.dto.response.CourseFindResponse; -import kr.co.yigil.travel.dto.response.CourseUpdateResponse; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.LineString; -import org.locationtech.jts.geom.Point; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class CourseServiceTest { - - @InjectMocks - private CourseService courseService; - - @Mock - private MemberService memberService; - - @Mock - private PostService postService; - - @Mock - private SpotService spotService; - - @Mock - private CourseRepository courseRepository; - - @Mock - private TravelRepository travelRepository; - - @Mock - private CommentService commentService; - - @DisplayName("createCourse 메서드가 유효한 인자를 넘겨받았을 때 올바른 응답을 내리는지.") - @Test - void GivenValidInput_WhenCreateCourse_ThenReturnValidPostCreateReponse(){ - - // Given - Long memberId = 1L; - String lineStringJson ="{ \"type\": \"LineString\", \"coordinates\": [[0, 0], [1, 1], [2, 2]] }"; - CourseCreateRequest courseCreateRequest = new CourseCreateRequest( - "title1", 1, List.of(11L, 12L, 13L), lineStringJson - ); - - Member mockMember = new Member("shin@gmail.com", "123456", "똷", "profile.jpg", "kakao"); - - Mockito.when(memberService.findMemberById(memberId)).thenReturn(mockMember); - - GeometryFactory geometryFactory = new GeometryFactory(); - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(11L, mockPoint1, false, "anyTitle1", "아무말1","anyImageUrl1"); - - Point mockPoint2 = geometryFactory.createPoint(new Coordinate(1,1)); - Spot mockSpot2 = new Spot(12L, mockPoint2, false, "anyTitle2", "아무말2","anyImageUrl2"); - - Point mockPoint3 = geometryFactory.createPoint(new Coordinate(2,2)); - Spot mockSpot3 = new Spot(13L, mockPoint3, false, "anyTitle3", "아무말","anyImageUrl3"); - - List mockSpotList = List.of(mockSpot1, mockSpot2, mockSpot3); - - List coordinates = List.of( - new Coordinate(1, 1), - new Coordinate(2, 2), - new Coordinate(3, 3) - ); - - LineString mockPath = geometryFactory.createLineString(coordinates.toArray(new Coordinate[0])); - Course mockCourse = new Course(mockPath, mockSpotList, 1, mockSpot1.getTitle()); - - when(courseRepository.save(any())).thenReturn(mockCourse); - - doNothing().when(postService).deleteOnlyPost(Mockito.anyLong(), Mockito.anyLong()); - when(spotService .getSpotListFromSpotIds(anyList())).thenReturn(mockSpotList); - - CourseCreateResponse response = courseService.createCourse(memberId, courseCreateRequest); - assertEquals("경로 생성 성공", response.getMessage()); - } - - @DisplayName("findCourse 메서드가 올바른 응답을 내리는지") - @Test - void Given_WhenfindCourse_Then(){ - - Long postId = 1L; - Member mockMember = new Member("shin@gmail.com", "123456", "똷", "profile.jpg", "kakao"); - - GeometryFactory geometryFactory = new GeometryFactory(); - List coordinates = List.of( - new Coordinate(1, 1), - new Coordinate(2, 2), - new Coordinate(3, 3) - ); - LineString mockPath = geometryFactory.createLineString(coordinates.toArray(new Coordinate[0])); - - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(11L, mockPoint1, false, "anyTitle1", "아무말1","anyImageUrl1"); - - Point mockPoint2 = geometryFactory.createPoint(new Coordinate(1,1)); - Spot mockSpot2 = new Spot(12L, mockPoint2, false, "anyTitle2", "아무말2","anyImageUrl2"); - - Point mockPoint3 = geometryFactory.createPoint(new Coordinate(2,2)); - Spot mockSpot3 = new Spot(13L, mockPoint3, false, "anyTitle3", "아무말","anyImageUrl3"); - - List mockSpotList = List.of(mockSpot1, mockSpot2, mockSpot3); - - Course mockCourse = new Course(mockPath, mockSpotList, 1, mockSpot1.getTitle()); - - Post mockPost = new Post(postId, mockCourse, mockMember); - - when(postService.findPostById(anyLong())).thenReturn(mockPost); - - when(commentService.getCommentList(mockCourse.getId())).thenReturn(List.of()); - - CourseFindResponse response = courseService.findCourse(postId); - assertThat(response).isInstanceOf(CourseFindResponse.class); - } - - @DisplayName("updateCourse 메서드가 유효한 인자를 넘겨받았을 때 올바른 응답을 내리는지") - @Test - void GivenValidParameter_WhenUpdateCourse_ThenReturnValidResponse(){ - Long postId = 1L; - Long memberId = 1L; - List spotIdList = Arrays.asList(11L, 12L, 13L); - List addedSpotIdList = Arrays.asList(14L, 15L); - List removedSpotIdList = Arrays.asList(11L); - - GeometryFactory geometryFactory = new GeometryFactory(); - CourseUpdateRequest request = new CourseUpdateRequest( - "title", - "{ \"type\": \"LineString\", \"coordinates\": [[0, 0], [1, 1], [2, 2]] }", - 1, - spotIdList, - removedSpotIdList, - addedSpotIdList - ); - - List coordinates = List.of( - new Coordinate(0, 0), - new Coordinate(1, 1), - new Coordinate(2, 2) - - ); - - LineString lineString = geometryFactory.createLineString(coordinates.toArray(new Coordinate[0])); - - // spotRepository mocking - Point mockPoint1 = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot1 = new Spot(11L, mockPoint1, false, "anyTitle1", "아무말1","anyImageUrl1"); - - Point mockPoint2 = geometryFactory.createPoint(new Coordinate(1,1)); - Spot mockSpot2 = new Spot(12L, mockPoint2, false, "anyTitle2", "아무말2","anyImageUrl2"); - - Point mockPoint3 = geometryFactory.createPoint(new Coordinate(2,2)); - Spot mockSpot3 = new Spot(13L, mockPoint3, false, "anyTitle3", "아무말","anyImageUrl3"); - - List mockSpotList = List.of(mockSpot1, mockSpot2, mockSpot3); - List mockTravelList = mockSpotList.stream().map(Travel.class::cast).toList(); - LineString mockPath = geometryFactory.createLineString(coordinates.toArray(new Coordinate[0])); - - // postService.findPostById 모킹 - Travel mockTravel = new Course(lineString, mockSpotList, 1, ""); - Member mockMember = new Member("kiit0901@gmail.com", "123456", "stone", "profile.jpg", "kakao"); - when(postService.findPostById(postId)).thenReturn(new Post(postId, mockTravel, mockMember)); - when(memberService.findMemberById(anyLong())).thenReturn(mockMember); - when(travelRepository.findAllById(spotIdList)).thenReturn(mockTravelList); - when(spotService.findSpotById(anyLong())).thenReturn(mockSpotList.get(0)); - doNothing().when(postService).deleteOnlyPost(Mockito.anyLong(), Mockito.anyLong()); // deleteOnlyPost 메서드가 호출되면 아무런 동작도 하지 않음 - when(courseRepository.save(Mockito.any())).thenReturn(new Course(mockPath, mockSpotList, 1, mockSpot1.getTitle())); // save 메서드가 호출되면 가상의 Course 객체 반환 - doNothing().when(postService).updatePost(Mockito.anyLong(), Mockito.any(), Mockito.any()); // updatePost 메서드가 호출되면 아무런 동작도 하지 않음 - - assertThat(courseService.updateCourse(postId, memberId, request)).isInstanceOf(CourseUpdateResponse.class); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/SpotFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/SpotFacadeTest.java new file mode 100644 index 000000000..8e8dce644 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/SpotFacadeTest.java @@ -0,0 +1,181 @@ +package kr.co.yigil.travel.application; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.AttachFiles; +import kr.co.yigil.file.FileType; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.global.Selected; +import kr.co.yigil.member.Ages; +import kr.co.yigil.member.Gender; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.SocialLoginType; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.spot.SpotCommand.ModifySpotRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterSpotRequest; +import kr.co.yigil.travel.domain.spot.SpotInfo; +import kr.co.yigil.travel.domain.spot.SpotInfo.Main; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpot; +import kr.co.yigil.travel.domain.spot.SpotService; +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.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +@ExtendWith(MockitoExtension.class) +public class SpotFacadeTest { + + @Mock + private SpotService spotService; + + @Mock + private FileUploader fileUploader; + + @InjectMocks + private SpotFacade spotFacade; + + @DisplayName("getSpotSliceInPlace 메서드가 Spot Slice를 잘 반환하는지") + @Test + void getSpotSliceInPlace_ShouldReturnSlice() { + Long placeId = 1L; + Accessor mockAccessor = mock(Accessor.class); + SpotInfo.Slice mockSlice = mock(SpotInfo.Slice.class); + Pageable pageable = PageRequest.of(0, 5); + when(spotService.getSpotSliceInPlace(eq(placeId), any(Accessor.class), any(Pageable.class))).thenReturn(mockSlice); + + SpotInfo.Slice result = spotFacade.getSpotSliceInPlace(placeId, mockAccessor, pageable); + + assertEquals(mockSlice, result); + verify(spotService).getSpotSliceInPlace(placeId, mockAccessor, pageable); + } + + @DisplayName("getMySpotInPlace 메서드가 응답을 잘 반환하는지") + @Test + void getMySpotInPlace_ShouldReturnResponse() { + SpotInfo.MySpot mySpot = mock(MySpot.class); + + when(spotService.retrieveMySpotInfoInPlace(anyLong(), anyLong())).thenReturn(mySpot); + + spotFacade.retrieveMySpotInfoInPlace(1L, 1L); + + verify(spotService).retrieveMySpotInfoInPlace(1L, 1L); + } + + @DisplayName("registerSpot 메서드가 SpotService를 잘 호출하는지") + @Test + void registerSpot_ShouldCallService() { + RegisterSpotRequest command = mock(RegisterSpotRequest.class); + Long memberId = 1L; + + doNothing().when(spotService).registerSpot(command, memberId); + + spotFacade.registerSpot(command, memberId); + + verify(spotService).registerSpot(command, memberId); + } + + @DisplayName("retrieveSpotinfo 메서드가 SpotInfo를 잘 반환하는지") + @Test + void retrieveSpotInfo_ShouldReturnSpotInfo() { + Long spotId = 1L; + Main expectedSpotInfo = mock(Main.class); + + when(spotService.retrieveSpotInfo(spotId)).thenReturn(expectedSpotInfo); + + Main result = spotFacade.retrieveSpotInfo(spotId); + + assertEquals(expectedSpotInfo, result); + verify(spotService).retrieveSpotInfo(spotId); + } + + @DisplayName("modifySpot 메서드가 SpotService를 잘 호출하는지") + @Test + void modifySpot_ShouldCallService() { + ModifySpotRequest command = mock(ModifySpotRequest.class); + Long spotId = 1L; + Long memberId = 1L; + + doNothing().when(spotService).modifySpot(command, spotId, memberId); + + spotFacade.modifySpot(command, spotId, memberId); + + verify(spotService).modifySpot(command, spotId, memberId); + } + + @DisplayName("deleteSpot 메서드가 SpotService를 잘 호출하는지") + @Test + void deleteSpot_ShouldCallService() { + Long spotId = 1L; + Long memberId = 1L; + + doNothing().when(spotService).deleteSpot(spotId, memberId); + + spotFacade.deleteSpot(spotId, memberId); + + verify(spotService).deleteSpot(spotId, memberId); + } + + @DisplayName("getMemberSpotInfo 메서드가 유효한 요청이 들어왔을 때 MemberInfo의 MemberSpotResponse 객체를 잘 반환하는지") + @Test + void WhenGetMemberSpotsInfo_ThenShouldReturnValidMemberSpotResponse() { + // Given + Long memberId = 1L; + int totalPages = 1; + PageRequest pageable = PageRequest.of(0, 5); + + String email = "test@test.com"; + String socialLoginId = "12345"; + String nickname = "tester"; + String profileImageUrl = "test.jpg"; + Member member = new Member(memberId, email, socialLoginId, nickname, profileImageUrl, + SocialLoginType.KAKAO, Ages.NONE, Gender.NONE); + + Long spotId = 1L; + String title = "Test Spot Title"; + double rate = 5.0; + AttachFile imageFile = new AttachFile(FileType.IMAGE, "test.jpg", "test.jpg", 10L); + AttachFiles imageFiles = new AttachFiles(Collections.singletonList(imageFile)); + + Place mockPlace = mock(Place.class); + when(mockPlace.getName()).thenReturn("장소명"); + Spot spot = new Spot(spotId, member, null, false, title, null, imageFiles, mockPlace, rate); + + SpotInfo.SpotListInfo spotInfo = new SpotInfo.SpotListInfo(spot); + List spotList = Collections.singletonList(spotInfo); + + SpotInfo.MySpotsResponse mockSpotListResponse = new SpotInfo.MySpotsResponse( + spotList, + totalPages + ); + + when(spotService.retrieveSpotList(anyLong(), any(Selected.class), any(Pageable.class))).thenReturn( + mockSpotListResponse); + + // When + var result = spotFacade.getMemberSpotsInfo(memberId, Selected.PRIVATE, pageable); + + // Then + assertThat(result).isNotNull() + .isInstanceOf(SpotInfo.MySpotsResponse.class) + .usingRecursiveComparison().isEqualTo(mockSpotListResponse); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/SpotServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/SpotServiceTest.java deleted file mode 100644 index bbdee242d..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/SpotServiceTest.java +++ /dev/null @@ -1,220 +0,0 @@ -package kr.co.yigil.travel.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.when; - -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import kr.co.yigil.comment.application.CommentService; -import kr.co.yigil.comment.domain.Comment; -import kr.co.yigil.comment.domain.repository.CommentRepository; -import kr.co.yigil.comment.dto.response.CommentResponse; -import kr.co.yigil.file.FileUploadEvent; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.global.exception.ExceptionCode; -import kr.co.yigil.member.application.MemberService; -import kr.co.yigil.member.domain.Member; -import kr.co.yigil.member.domain.SocialLoginType; -import kr.co.yigil.post.application.PostService; -import kr.co.yigil.post.domain.Post; -import kr.co.yigil.travel.domain.Course; -import kr.co.yigil.travel.domain.Spot; -import kr.co.yigil.travel.domain.repository.SpotRepository; -import kr.co.yigil.travel.dto.request.SpotCreateRequest; -import kr.co.yigil.travel.dto.request.SpotUpdateRequest; -import kr.co.yigil.travel.dto.response.SpotCreateResponse; -import kr.co.yigil.travel.dto.response.SpotFindResponse; -import kr.co.yigil.travel.dto.response.SpotUpdateResponse; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.LineString; -import org.locationtech.jts.geom.Point; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.mock.web.MockMultipartFile; - -@ExtendWith(MockitoExtension.class) -class SpotServiceTest { - - @InjectMocks - private SpotService spotService; - @Mock - private PostService postService; - @Mock - private MemberService memberService; - @Mock - private ApplicationEventPublisher applicationEventPublisher; - @Mock - private SpotRepository spotRepository; - @Mock - private CommentService commentService; - - @DisplayName("createSpot 메서드가 유효한 인자를 넘겨받았을 때 올바른 응답을 내리는지.") - @Test - void GivenValidInput_WhenCreateSpotTest_thenReturnValidPostId() throws Exception { - - // mock request 설정 - String geoJson = "{ \"type\": \"Point\", \"coordinates\": [0, 0] }"; -// MultipartFile multipartFile = new MultipartFile( ); - MockMultipartFile imageFile = new MockMultipartFile("file", "filename.jpg", "image/jpeg", new byte[10]); - SpotCreateRequest mockRequest = new SpotCreateRequest(geoJson, "title1", "description", imageFile); - - // mock member 설정 - Long memberId = 1L; - Member mockMember = new Member("shin@gmail.com", "123456", "똷", "profile.jpg", SocialLoginType.KAKAO); - when(memberService.findMemberById(memberId)).thenReturn(mockMember); - - // MockMvc를 사용하여 파일 업로드 시뮬레이션 - doNothing().when(applicationEventPublisher).publishEvent(any()); // publishEvent 메서드가 호출되면 아무런 동작도 하지 않음 - - // when - SpotCreateResponse response = spotService.createSpot(memberId, mockRequest); - - // 예상 결과 확인 - assertEquals("스팟 정보 생성 성공", response.getMessage()); - } - - @DisplayName("findSpot 메서드가 올바른 응답을 내리는지") - @Test - void GivenValidPostId_WhenFindSpot_ThenReturnSpotFindResponse() { - - Long postId = 123L; - GeometryFactory geometryFactory = new GeometryFactory(); - Member mockMember = new Member("shin@gmail.com", "123456", "똷", "profile.jpg", "kakao"); - Point mockPoint = geometryFactory.createPoint(new Coordinate(0,0)); - Spot mockSpot = new Spot(mockPoint, false, "anyTitle", "아무말","anyImageUrl"); - Post mockPost = new Post(postId, mockSpot, mockMember); - when(postService.findPostById(anyLong())).thenReturn(mockPost); - - CommentResponse mockCommentResponse1 = new CommentResponse(); - CommentResponse mockCommentResponse2 = new CommentResponse(); - List mockCommentResponseList = List.of(mockCommentResponse1, mockCommentResponse2); - when(commentService.getCommentList(mockSpot.getId())).thenReturn(mockCommentResponseList); -// when(commentService.getCommentList(anyLong())).thenReturn(mockCommentResponseList); //todo 이건 왜 안되나요 ㅜㅜㅜㅜ - - SpotFindResponse spotFindResponse = SpotFindResponse.from(mockMember, mockSpot, mockCommentResponseList); - - assertThat(spotService.findSpotByPostId(postId)).isEqualTo(spotFindResponse); - } - - @DisplayName("findSpot 메서드에서 Travel을 course로 가지고 있는 Post Id로 Spot을 찾을때 예외가 제대로 나오는지 확인") - @Test - void GivenInValidPostId_WhenFindSpot_ThenThrowBadRequestException() { - Long postId = 123L; - Member mockMember = new Member("shin@gmail.com", "123456", "God", "profile.jpg", "kakao"); - - GeometryFactory geometryFactory = new GeometryFactory(); - List coordinates = List.of( - new Coordinate(1.0, 1.0), - new Coordinate(2.0, 2.0), - new Coordinate(3.0, 3.0) - ); - LineString mockLineString = geometryFactory.createLineString(coordinates.toArray(new Coordinate[0])); - - Point mockPoint = geometryFactory.createPoint(new Coordinate(0,0)); - mockPoint.setSRID(4326); - - Spot mockSpot = new Spot(mockPoint, false, "title", "www.image.com", "hello, im description"); - List mockSpotList = List.of(mockSpot); - Course mockCourse = new Course(mockLineString, mockSpotList, 2, "mock title"); - Post mockPost = new Post(postId, mockCourse, mockMember); - when(postService.findPostById(anyLong())).thenReturn(mockPost); - - assertThrows(BadRequestException.class, ()-> spotService.findSpotByPostId(postId)); - } - - @DisplayName("updateSpot 메서드가 유효한 인자를 받았을 때 올바른 응답을 반환하는지") - @Test - void givenValidParameter_whenUpdateSpot_thenReturnSpotUpdateResponse() { - Long memberId = 2L; - Long postId = 2L; - MockMultipartFile multipartFile = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "Test".getBytes() - ); - - SpotUpdateRequest spotUpdateRequest = new SpotUpdateRequest( - "{ \"type\": \"Point\", \"coordinates\": [0.0, 0.1] }", false, "title", "desc", multipartFile - ); - - GeometryFactory geometryFactory = new GeometryFactory(); - Point location = geometryFactory.createPoint(new Coordinate(0,0)); - location.setSRID(4326); - - Spot mockSpot = new Spot(1L, location, false, "title", "desc", "mockUrl"); - Member mockMember = new Member("kiit0901@gmail.com", "123456", "stone", "profile.jpg", "kakao"); - - when(postService.findPostById(postId)).thenReturn(new Post(postId, mockSpot, mockMember)); - when(memberService.findMemberById(memberId)).thenReturn(mockMember); - when(spotRepository.save(any(Spot.class))).thenReturn(mockSpot); - - doAnswer(invocation -> { - FileUploadEvent event = invocation.getArgument(0); - event.getCallback().accept("mockUrl"); - return null; - }).when(applicationEventPublisher).publishEvent(any(FileUploadEvent.class)); - - SpotUpdateResponse response = spotService.updateSpot(memberId, postId, spotUpdateRequest); - - assertThat(response).isNotNull(); - assertThat(response.getPointJson()).isNotNull(); - } - - - @DisplayName("유효한 spotId가 주어졌을 때 유효한 spot이 반환되는지") - @Test - void GivenValidSpotId_WhenFindSpotById_ThenReturnTavel() { - - Spot spot = new Spot(1L); - when(spotRepository.findById(anyLong())).thenReturn(Optional.of(spot)); - assertThat(spotService.findSpotById(anyLong())).isInstanceOf(Spot.class); - } - - @DisplayName("유효하지 않은 spotId가 주어졌을 때 예외가 발생하는지") - @Test - void GivenINValidSpotId_WhenFindSpotById_ThenThrowBadRequestException() { - when(spotRepository.findById(anyLong())).thenThrow(new BadRequestException(ExceptionCode.NOT_FOUND_SPOT_ID)); - assertThrows(BadRequestException.class, () -> spotService.findSpotById(anyLong())); - } - - @Test - @DisplayName("getSpotListFromSpotIds 메서드 테스트") - void testGetSpotListFromSpotIds() { - // Given - List spotIdList = Arrays.asList(1L, 2L, 3L); - List mockedSpotList = Arrays.asList( - new Spot(1L), - new Spot(2L), - new Spot(3L) - ); - - // Mocking spotRepository.findById - when(spotRepository.findById(anyLong())) - .thenAnswer(invocation -> { - Long spotId = invocation.getArgument(0); - return Optional.ofNullable(mockedSpotList.stream() - .filter(spot -> spot.getId().equals(spotId)) - .findFirst() - .orElse(null)); - }); - - // Mocking castTravelToSpot - List resultSpotList = spotService.getSpotListFromSpotIds(spotIdList); - - // Then - assertThat(resultSpotList).hasSize(3); - // Add more assertions based on your logic and expected behavior - } - -} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/TravelFacadeTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/TravelFacadeTest.java new file mode 100644 index 000000000..dc26d4757 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/TravelFacadeTest.java @@ -0,0 +1,56 @@ +package kr.co.yigil.travel.application; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import kr.co.yigil.travel.domain.TravelCommand; +import kr.co.yigil.travel.domain.TravelService; +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; + +@ExtendWith(MockitoExtension.class) +public class TravelFacadeTest { + + @Mock + private TravelService travelService; + + @InjectMocks + private TravelFacade travelFacade; + + @DisplayName("changeOnPublicTravel 메서드가 TravelService를 잘 호출하는지") + @Test + void changeOnPublicTravel_ShouldCallService() { + Long travelId = 1L; + Long memberId = 1L; + + travelFacade.changeOnPublicTravel(travelId, memberId); + + verify(travelService).changeOnPublic(travelId, memberId); + } + + @DisplayName("changeOnPrivateTravel 메서드가 TravelService를 잘 호출하는지") + @Test + void changeOnPrivateTravel_ShouldCallService() { + Long travelId = 1L; + Long memberId = 1L; + + travelFacade.changeOnPrivateTravel(travelId, memberId); + + verify(travelService).changeOnPrivate(travelId, memberId); + } + + @DisplayName("setTravelsVisibility 메서드가 유효한 요청이 들어왔을 때 TravelService의 setTravelsVisibility 메서드를 잘 호출하는지") + @Test + void WhenSetTravelsVisibility_ThenShouldReturnVisibilityChangeResponse() { + Long memberId = 1L; + TravelCommand.VisibilityChangeRequest command = mock(TravelCommand.VisibilityChangeRequest.class); + + travelFacade.setTravelsVisibility(memberId, command); + + verify(travelService).setTravelsVisibility(memberId, command); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/TravelServiceTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/TravelServiceTest.java deleted file mode 100644 index 6a05021aa..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/travel/application/TravelServiceTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package kr.co.yigil.travel.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import kr.co.yigil.global.exception.BadRequestException; -import kr.co.yigil.travel.domain.Travel; -import kr.co.yigil.travel.domain.repository.TravelRepository; -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; - -@ExtendWith(MockitoExtension.class) -class TravelServiceTest { - @InjectMocks - private TravelService travelService; - @Mock - private TravelRepository travelRepository; - - @Test - void GivenValidTravelId_WhenFindTravelById_ThenReturnTravel() { - Travel travel = new Travel(1L); - when(travelRepository.findById(anyLong())).thenReturn(Optional.of(travel)); - assertThat(travelService.findTravelById(anyLong())).isInstanceOf(Travel.class); - } - - @Test - void GivenInvalidTravelId_WhenFindTravelById_ThenThrowException() { - when(travelRepository.findById(anyLong())).thenReturn(Optional.empty()); - assertThrows(BadRequestException.class, () -> travelService.findTravelById(anyLong())); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/domain/TravelServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/domain/TravelServiceImplTest.java new file mode 100644 index 000000000..76258ee9d --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/domain/TravelServiceImplTest.java @@ -0,0 +1,118 @@ +package kr.co.yigil.travel.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.co.yigil.global.exception.AuthException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.member.Member; +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; + +@ExtendWith(MockitoExtension.class) +public class TravelServiceImplTest { + + @Mock + private TravelReader travelReader; + + @InjectMocks + private TravelServiceImpl travelService; + + private Travel travel; + private Member member; + + @BeforeEach + void setUp() { + + } + + @DisplayName("changeOnPublic 메서드가 유효한 memberId가 있을 때 travel의 상태를 public으로 바꾸는지") + @Test + void changeOnPublic_WithValidMemberId_ShouldChangeTravelToPublic() { + travel = mock(Travel.class); + member = mock(Member.class); + when(travel.getMember()).thenReturn(member); + when(travelReader.getTravel(1L)).thenReturn(travel); + + doNothing().when(travel).changeOnPublic(); + when(member.getId()).thenReturn(1L); + travelService.changeOnPublic(1L, 1L); + verify(travel).changeOnPublic(); + } + + @DisplayName("changeOnPublic 메서드가 유효하지 않은 memberId가 있을 때 예외를 잘 발생시키는지") + @Test + void changeOnPublic_WithInvalidMemberId_ShouldThrowAuthException() { + travel = mock(Travel.class); + member = mock(Member.class); + when(travel.getMember()).thenReturn(member); + when(travelReader.getTravel(1L)).thenReturn(travel); + + Exception exception = assertThrows(AuthException.class, () -> { + travelService.changeOnPublic(1L, 1L); + }); + + assertEquals(ExceptionCode.INVALID_AUTHORITY.getMessage(), exception.getMessage()); + } + + @DisplayName("changeOnPrivate 메서드가 유효한 memberId가 있을 때 travel의 상태를 private으로 바꾸는지") + @Test + void changeOnPrivate_WithValidMemberId_ShouldChangeTravelToPrivate() { + travel = mock(Travel.class); + member = mock(Member.class); + when(travel.getMember()).thenReturn(member); + when(travelReader.getTravel(1L)).thenReturn(travel); + + doNothing().when(travel).changeOnPrivate(); + when(member.getId()).thenReturn(1L); + travelService.changeOnPrivate(1L, 1L); + verify(travel).changeOnPrivate(); + } + + @DisplayName("changeOnPrivate 메서드가 유효하지 않은 memberId가 있을 때 예외를 잘 발생시키는지") + @Test + void changeOnPrivate_WithInvalidMemberId_ShouldThrowAuthException() { + travel = mock(Travel.class); + member = mock(Member.class); + when(travel.getMember()).thenReturn(member); + when(travelReader.getTravel(1L)).thenReturn(travel); + + Exception exception = assertThrows(AuthException.class, () -> { + travelService.changeOnPrivate(1L, 1L); + }); + + assertEquals(ExceptionCode.INVALID_AUTHORITY.getMessage(), exception.getMessage()); + } + + @DisplayName("setTravelsVisibility 를 호출했을 때 여행 리스트의 공개 여부가 잘 변경되는지 확인") + @Test + void WhenSetTravelsVisibility_ThenReturnVisibilityChangeResponse() { + Long memberId = 1L; + Long travelId1 = 1L; + Long travelId2 = 2L; + + TravelCommand.VisibilityChangeRequest command = new TravelCommand.VisibilityChangeRequest( + List.of(travelId1, travelId2), false); + Member owner = new Member(memberId, null, null, null, null, null); + Travel travel1 = new Travel(travelId1, owner, null, null, 0, true); + Travel travel2 = new Travel(travelId2, owner, null, null, 0, true); + + when(travelReader.getTravels(List.of(travelId1, travelId2))).thenReturn(List.of(travel1, travel2)); + + travelService.setTravelsVisibility(memberId, command); + + assertFalse(travel1.isPrivate()); + assertFalse(travel2.isPrivate()); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/domain/course/CourseServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/domain/course/CourseServiceImplTest.java new file mode 100644 index 000000000..ca2d695fa --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/domain/course/CourseServiceImplTest.java @@ -0,0 +1,209 @@ +package kr.co.yigil.travel.domain.course; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.global.Selected; +import kr.co.yigil.global.exception.AuthException; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.course.CourseCommand.ModifyCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequest; +import kr.co.yigil.travel.domain.course.CourseInfo.Main; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.locationtech.jts.geom.LineString; +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.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +public class CourseServiceImplTest { + + @Mock + private MemberReader memberReader; + @Mock + private CourseReader courseReader; + @Mock + private CourseStore courseStore; + @Mock + private CourseSeriesFactory courseSeriesFactory; + @Mock + private CourseSpotSeriesFactory courseSpotSeriesFactory; + + @Mock + private FileUploader fileUploader; + + @InjectMocks + private CourseServiceImpl courseService; + + @DisplayName("getCoursesSliceInPlace 메서드가 Course의 Slice를 잘 반환하는지") + @Test + void getCoursesSliceInPlace_ReturnsSlice() { + Long placeId = 1L; + Pageable pageable = PageRequest.of(0, 10); + Slice expectedSlice = mock(Slice.class); + + when(courseReader.getCoursesSliceInPlace(placeId, pageable)).thenReturn(expectedSlice); + + Slice result = courseService.getCoursesSliceInPlace(placeId, pageable); + + assertEquals(expectedSlice, result); + verify(courseReader).getCoursesSliceInPlace(placeId, pageable); + } + + @DisplayName("registerCourse 메서드가 Course를 잘 생성하는지") + @Test + void registerCourse_storeCourse() { + Long memberId = 1L; + RegisterCourseRequest command = mock(RegisterCourseRequest.class); + Member member = mock(Member.class); + List spots = new ArrayList<>(); + Course course = mock(Course.class); + MultipartFile mockMultipartFile = mock(MultipartFile.class); + AttachFile mockAttachFile = mock(AttachFile.class); + + when(command.getMapStaticImageFile()).thenReturn(mockMultipartFile); + when(memberReader.getMember(memberId)).thenReturn(member); + when(courseSpotSeriesFactory.store(command, memberId)).thenReturn(spots); + when(fileUploader.upload(mockMultipartFile)).thenReturn(mockAttachFile); + when(command.toEntity(spots, member, mockAttachFile)).thenReturn(course); + + courseService.registerCourse(command, memberId); + + verify(courseStore).store(any(Course.class)); + } + + @DisplayName("retrieveCourseInfo 메서드가 CourseInfo를 잘 반환하는지") + @Test + void retrieveCourseInfo_ShouldReturnCourseInfo() { + Long courseId = 1L; + Course course = mock(Course.class); + when(courseReader.getCourse(courseId)).thenReturn(course); + when(course.getMapStaticImageFileUrl()).thenReturn("~~~"); + Main result = courseService.retrieveCourseInfo(courseId); + + assertNotNull(result); + verify(courseReader).getCourse(courseId); + } + + @DisplayName("modifyCourse 메서드가 유효한 memberId가 있을 때 엔티티를 잘 수정하는지") + @Test + void modifyCourse_WithValidMemberId_ModifiesCourse() { + Long courseId = 1L, memberId = 1L; + ModifyCourseRequest command = mock(ModifyCourseRequest.class); + Course course = mock(Course.class); + Member member = mock(Member.class); + + when(course.getMember()).thenReturn(member); + when(member.getId()).thenReturn(memberId); + when(courseReader.getCourse(courseId)).thenReturn(course); + + courseService.modifyCourse(command, courseId, memberId); + + verify(courseSeriesFactory).modify(command, course); + } + + @DisplayName("modifyCourse 메서드가 유효하지 않은 memberId가 있을 때 예외를 잘 발생시키는지") + @Test + void modifyCourse_WithInvalidMemberId_ThrowsAuthException() { + Long courseId = 1L, memberId = 2L; + ModifyCourseRequest command = mock(ModifyCourseRequest.class); + Course course = mock(Course.class); + Member member = mock(Member.class); + + when(course.getMember()).thenReturn(member); + when(member.getId()).thenReturn(1L); + when(courseReader.getCourse(courseId)).thenReturn(course); + + assertThrows(AuthException.class, () -> courseService.modifyCourse(command, courseId, memberId)); + } + + @DisplayName("deleteCourse 메서드가 유효한 memberId가 있을 때 잘 삭제시키는지") + @Test + void deleteCourse_WithValidMemberId_DeleteCourse() { + Long courseId = 1L, memberId = 1L; + Course course = mock(Course.class); + Member member = mock(Member.class); + + when(course.getMember()).thenReturn(member); + when(member.getId()).thenReturn(memberId); + when(courseReader.getCourse(courseId)).thenReturn(course); + + courseService.deleteCourse(courseId, memberId); + + verify(courseStore).remove(course); + } + + @DisplayName("deleteCourse 메서드가 유효하지 않은 memberId가 있을 때 예외를 잘 발생시키는지") + @Test + void deleteCourse_WithInvalidMemberId_ShouldThrowAuthException() { + Long courseId = 1L, memberId = 2L; + Course course = mock(Course.class); + Member member = mock(Member.class); + + when(course.getMember()).thenReturn(member); + when(member.getId()).thenReturn(1L); + when(courseReader.getCourse(courseId)).thenReturn(course); + + assertThrows(AuthException.class, () -> courseService.deleteCourse(courseId, memberId)); + } + + @DisplayName("retrieveCourseList 를 호출했을 때 코스 리스트 조회가 잘 되는지 확인") + @Test + void WhenRetrieveCourseList_ThenShouldReturnCourseListResponse() { + Long memberId = 1L; + Long courseId = 1L; + PageRequest pageable = PageRequest.of(0, 10); + + // 필요 course 필드: id, title, rate, spotList, mapstaticImageUrl + + Course mockCourse = new Course( + courseId, mock(Member.class), "title", null, 4.5, mock(LineString.class), false, + List.of(mock(Spot.class)), 1, mock(AttachFile.class)); + PageImpl mockCourseList = new PageImpl<>(List.of(mockCourse)); + + + when(courseReader.getMemberCourseList(anyLong(), any(), any())).thenReturn(mockCourseList); + + var result = courseService.retrieveCourseList(memberId, pageable, Selected.ALL); + + assertThat(result).isNotNull().isInstanceOf(CourseInfo.MyCoursesResponse.class); + assertThat(result.getContent().getFirst()).isInstanceOf(CourseInfo.CourseListInfo.class); + } + + @DisplayName("searchCourseByPlaceName 메서드가 잘 동작하는지") + @Test + void WhenSearchCourseByPlaceName_ThenShouldReturnValidSlice() { + String keyword = "test"; + Pageable pageable = PageRequest.of(0, 10); + Accessor mockAccessor = mock(Accessor.class); + Slice mockSlice = mock(Slice.class); + + when(courseReader.searchCourseByPlaceName(keyword, pageable)).thenReturn(mockSlice); + + var result = courseService.searchCourseByPlaceName(keyword, mockAccessor, pageable); + + assertThat(result).isNotNull().isInstanceOf(CourseInfo.Slice.class); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/domain/spot/SpotServiceImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/domain/spot/SpotServiceImplTest.java new file mode 100644 index 000000000..08c5f54e7 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/domain/spot/SpotServiceImplTest.java @@ -0,0 +1,306 @@ +package kr.co.yigil.travel.domain.spot; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.AttachFiles; +import kr.co.yigil.file.FileType; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.global.Selected; +import kr.co.yigil.global.exception.AuthException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCacheStore; +import kr.co.yigil.place.domain.PlaceReader; +import kr.co.yigil.place.domain.PlaceStore; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.spot.SpotCommand.ModifySpotRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterPlaceRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.RegisterSpotRequest; +import kr.co.yigil.travel.domain.spot.SpotInfo.Main; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.locationtech.jts.geom.Point; +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.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +@ExtendWith(MockitoExtension.class) +public class SpotServiceImplTest { + + @Mock + private MemberReader memberReader; + @Mock + private SpotReader spotReader; + @Mock + private PlaceReader placeReader; + + @Mock + private SpotStore spotStore; + @Mock + private PlaceStore placeStore; + @Mock + private PlaceCacheStore placeCacheStore; + + @Mock + private SpotSeriesFactory spotSeriesFactory; + @Mock + private FileUploader fileUploader; + + @InjectMocks + private SpotServiceImpl spotService; + + @DisplayName("getSpotSliceInPlace 메서드가 Spot의 Slice를 잘 반환하는지") + @Test + void getSpotSliceInPlace_ShouldReturnSlice() { + Long placeId = 1L; + Pageable pageable = mock(Pageable.class); + Accessor mockAccessor = mock(Accessor.class); + Slice expectedSlice = mock(Slice.class); + + when(spotReader.getSpotSliceInPlace(placeId, pageable)).thenReturn(expectedSlice); + + SpotInfo.Slice result = spotService.getSpotSliceInPlace(placeId, mockAccessor, pageable); + + verify(spotReader).getSpotSliceInPlace(placeId, pageable); + } + + @DisplayName("retrieveMySpotInfoInPlace 메서드가 존재하는 Spot에 대한 응답을 잘 반환하는지") + @Test + void retrieveMySpotInfoInPlace_ShouldReturnResponse_WithExistingSpot() { + Spot mockSpot = mock(Spot.class); + Optional optionalSpot = Optional.of(mockSpot); + AttachFiles attachFiles = mock(AttachFiles.class); + + when(mockSpot.getAttachFiles()).thenReturn(attachFiles); + when(attachFiles.getUrls()).thenReturn(List.of("images/image.jpg", "images/thumb.jpg")); + when(mockSpot.getRate()).thenReturn(3.5); + when(mockSpot.getCreatedAt()).thenReturn(LocalDateTime.now()); + when(mockSpot.getDescription()).thenReturn("description"); + + when(spotReader.findSpotByPlaceIdAndMemberId(anyLong(), anyLong())).thenReturn(optionalSpot); + + MySpot result = spotService.retrieveMySpotInfoInPlace(1L, 1L); + assertNotNull(result); + assertTrue(result.isExists()); + } + + @DisplayName("retrieveMySpotInfoInPlace 메서드가 존재하지 않는 Spot에 대한 응답을 잘 반환하는지") + @Test + void retrieveMySpotInfoInPlace_ShouldReturnResponse_WithNonExistingSpot() { + Optional optionalSpot = mock(Optional.class); + when(optionalSpot.isEmpty()).thenReturn(true); + + when(spotReader.findSpotByPlaceIdAndMemberId(anyLong(), anyLong())).thenReturn(optionalSpot); + + MySpot result = spotService.retrieveMySpotInfoInPlace(1L, 1L); + + assertNotNull(result); + assertFalse(result.isExists()); + } + + @DisplayName("registerSpot 메서드가 이미 존재하는 Place에 대한 요청을 통해 Spot을 잘 저장하는지") + @Test + void registerSpot_WithExistingPlace_ShouldStoreSpot() { + RegisterSpotRequest command = mock(RegisterSpotRequest.class); + RegisterPlaceRequest placeCommand = mock(RegisterPlaceRequest.class); + Long memberId = 1L; + Member member = mock(Member.class); + Long placeId = 1L; + String placeName = "Test Place"; + String placeAddress = "Test Address"; + Place place = new Place(placeId, placeName, placeAddress, 0.0, null, null, null, null); + Spot spot = mock(Spot.class); + AttachFiles mockAttachFiles = mock(AttachFiles.class); + + when(command.getRegisterPlaceRequest()).thenReturn(placeCommand); + when(spotSeriesFactory.initAttachFiles(command)).thenReturn(mockAttachFiles); + when(command.toEntity(member, place, false, mockAttachFiles)).thenReturn(spot); + when(placeCommand.getPlaceName()).thenReturn(placeName); + when(placeCommand.getPlaceAddress()).thenReturn(placeAddress); + when(memberReader.getMember(memberId)).thenReturn(member); + when(placeReader.findPlaceByNameAndAddress(anyString(), anyString())).thenReturn(Optional.of(place)); + when(spotStore.store(any(Spot.class))).thenReturn(spot); + + spotService.registerSpot(command, memberId); + + verify(spotStore).store(any(Spot.class)); + verify(placeCacheStore).incrementSpotCountInPlace(anyLong()); + } + + @DisplayName("registerSpot 메서드가 새로운 Place와 Spot을 잘 저장하는지") + @Test + void registerSpot_WithNewPlace_ShouldRegisterPlaceAndSpot() { + RegisterSpotRequest command = mock(RegisterSpotRequest.class); + Long memberId = 1L; + Member member = mock(Member.class); + Long placeId = 1L; + String placeName = "Test Place"; + String placeAddress = "Test Address"; + Place place = new Place(placeId, placeName, placeAddress, 0.0, null, null, null, null); + Spot spot = mock(Spot.class); + RegisterPlaceRequest placeCommand = mock(RegisterPlaceRequest.class); + AttachFiles mockAttachFiles = mock(AttachFiles.class); + + when(memberReader.getMember(memberId)).thenReturn(member); + when(command.getRegisterPlaceRequest()).thenReturn(placeCommand); + when(spotSeriesFactory.initAttachFiles(command)).thenReturn(mockAttachFiles); + when(command.toEntity(member, place, false, mockAttachFiles)).thenReturn(spot); + when(placeCommand.getPlaceName()).thenReturn(placeName); + when(placeCommand.getPlaceAddress()).thenReturn(placeAddress); + when(placeReader.findPlaceByNameAndAddress(anyString(), anyString())).thenReturn(Optional.empty()); + when(placeStore.store(any())).thenReturn(place); + when(spotStore.store(any(Spot.class))).thenReturn(spot); + + spotService.registerSpot(command, memberId); + + verify(fileUploader, times(2)).upload(any()); + verify(placeStore).store(any()); + verify(spotStore).store(any(Spot.class)); + } + + @DisplayName("retrieveSpotInfo 메서드가 SpotInfo를 잘 반환하는지") + @Test + void retrieveSpotInfo_ShouldReturnSpotInfo() { + Long spotId = 1L; + Spot spot = mock(Spot.class); + Place place = mock(Place.class); + AttachFiles attachFiles = mock(AttachFiles.class); + Member member = mock(Member.class); + + when(spotReader.getSpot(spotId)).thenReturn(spot); + when(spot.getPlace()).thenReturn(place); + when(spot.getAttachFiles()).thenReturn(attachFiles); + when(place.getMapStaticImageFileUrl()).thenReturn("~~"); + when(spot.getMember()).thenReturn(member); + when(member.getProfileImageUrl()).thenReturn("image.utl"); + when(member.getNickname()).thenReturn("nickname"); + + Main result = spotService.retrieveSpotInfo(spotId); + + assertNotNull(result); + } + + @DisplayName("modifySpot 메서드가 유효한 memberId가 주어졌을 때 spot을 잘 수정하는지") + @Test + void modifySpot_WithValidMemberId_ShouldModifySpot() { + ModifySpotRequest command = mock(ModifySpotRequest.class); + Long spotId = 1L; + Long memberId = 1L; + Spot spot = mock(Spot.class); + Member member = mock(Member.class); + + when(spot.getMember()).thenReturn(member); + when(spotReader.getSpot(spotId)).thenReturn(spot); + when(member.getId()).thenReturn(memberId); + + spotService.modifySpot(command, spotId, memberId); + + verify(spotSeriesFactory).modify(command, spot); + } + + @DisplayName("modifySpot 메서드가 유효하지 않은 memberId가 주어졌을 때 예외를 잘 발생시키는지") + @Test + void modifySpot_WithInvalidMemberId_ShouldThrowAuthException() { + ModifySpotRequest command = mock(ModifySpotRequest.class); + Long spotId = 1L; + Long memberId = 2L; + Spot spot = mock(Spot.class); + Member member = mock(Member.class); + + when(spot.getMember()).thenReturn(member); + when(member.getId()).thenReturn(1L); + when(spotReader.getSpot(spotId)).thenReturn(spot); + + Exception exception = assertThrows(AuthException.class, () -> { + spotService.modifySpot(command, spotId, memberId); + }); + + assertEquals(ExceptionCode.INVALID_AUTHORITY.getMessage(), exception.getMessage()); + } + + @DisplayName("deleteSpot 메서드가 유효한 memberId가 주어졌을 때 spot을 잘 삭제하는지") + @Test + void deleteSpot_WithValidMemberId_ShouldRemoveSpot() { + Long spotId = 1L; + Long memberId = 1L; + Spot spot = mock(Spot.class); + Member member = mock(Member.class); + + when(spot.getMember()).thenReturn(member); + when(member.getId()).thenReturn(memberId); + when(spotReader.getSpot(spotId)).thenReturn(spot); + + spotService.deleteSpot(spotId, memberId); + + verify(spotStore).remove(spot); + } + + @DisplayName("deleteSpot 메서드가 유효하지 않은 memberId가 주어졌을 때 예외를 잘 발생시키는지") + @Test + void deleteSpot_WithInvalidMemberId_ShouldThrowAuthException() { + Long spotId = 1L; + Long memberId = 2L; + Spot spot = mock(Spot.class); + Member member = mock(Member.class); + + when(spot.getMember()).thenReturn(member); + when(member.getId()).thenReturn(1L); + when(spotReader.getSpot(spotId)).thenReturn(spot); + + Exception exception = assertThrows(AuthException.class, () -> { + spotService.deleteSpot(spotId, memberId); + }); + + assertEquals(ExceptionCode.INVALID_AUTHORITY.getMessage(), exception.getMessage()); + } + + @DisplayName("retrieveSpotList 를 호출했을 때 스팟 리스트 조회가 잘 되는지 확인") + @Test + void WhenRetrieveSpotList_ThenShouldReturnSpotListResponse() { + Long memberId = 1L; + Long spotId = 1L; + PageRequest pageable = PageRequest.of(0, 10); + AttachFile mockAttachFile = new AttachFile(mock(FileType.class), "fileUrl", + "originalFileName", 100L); + AttachFiles mockAttachFiles = new AttachFiles(List.of(mockAttachFile)); + Member mockMember = mock(Member.class); + Place mockPlace = mock(Place.class); + Spot mockSpot = new Spot(spotId, mockMember, mock(Point.class), false, "title", + "description", mockAttachFiles, mockPlace, 3.5); + PageImpl mockSpotList = new PageImpl<>(List.of(mockSpot)); + when(mockPlace.getName()).thenReturn("장소장소"); + when(spotReader.getMemberSpotList(anyLong(), any(Selected.class), any())).thenReturn(mockSpotList); + + var result = spotService.retrieveSpotList(memberId, Selected.PRIVATE, pageable); + + assertThat(result).isNotNull().isInstanceOf(SpotInfo.MySpotsResponse.class); + assertThat(result.getContent().getFirst()).isInstanceOf(SpotInfo.SpotListInfo.class); + } + + +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/TravelReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/TravelReaderImplTest.java new file mode 100644 index 000000000..d018faaf8 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/TravelReaderImplTest.java @@ -0,0 +1,50 @@ +package kr.co.yigil.travel.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.global.exception.ExceptionCode; +import kr.co.yigil.travel.domain.Travel; +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; + +@ExtendWith(MockitoExtension.class) +public class TravelReaderImplTest { + + @Mock + private TravelRepository travelRepository; + + @InjectMocks + private TravelReaderImpl travelReader; + + @DisplayName("getTravel 메서드가 Travel이 존재할 때 값을 잘 반환하는지") + @Test + void getTravel_ReturnsTravel() { + Long travelId = 1L; + Travel expectedTravel = mock(Travel.class); + when(travelRepository.findById(travelId)).thenReturn(Optional.of(expectedTravel)); + + Travel result = travelReader.getTravel(travelId); + + assertEquals(expectedTravel, result); + } + + @DisplayName("getTravel 메서드가 Travel이 존재하지 않을 때 예외를 잘 발생시키는지") + @Test + void getTravel_ThrowsBadRequestException_WhenNotFound() { + Long travelId = 1L; + when(travelRepository.findById(travelId)).thenReturn(Optional.empty()); + + Exception exception = assertThrows(BadRequestException.class, () -> travelReader.getTravel(travelId)); + + assertEquals(ExceptionCode.NOT_FOUND_TRAVEL_ID.getMessage(), exception.getMessage()); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseReaderImplTest.java new file mode 100644 index 000000000..aa4c85c32 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseReaderImplTest.java @@ -0,0 +1,75 @@ +package kr.co.yigil.travel.infrastructure.course; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.infrastructure.CourseRepository; +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.Pageable; +import org.springframework.data.domain.Slice; + +@ExtendWith(MockitoExtension.class) +public class CourseReaderImplTest { + + @Mock + private CourseRepository courseRepository; + + @InjectMocks CourseReaderImpl courseReader; + + @DisplayName("getCourse 메서드가 Course를 잘 반환하는지") + @Test + void getCourse_ReturnsCourse() { + Long courseId = 1L; + Course expectedCourse = mock(Course.class); + when(courseRepository.findById(courseId)).thenReturn(Optional.of(expectedCourse)); + + Course result = courseReader.getCourse(courseId); + + assertEquals(expectedCourse, result); + } + + @DisplayName("getCourse 메서드가 Course가 존재하지 않을 때 예외를 잘 발생시키는지") + @Test + void getCourse_ThrowsBadRequestException_WhenNotFound() { + Long courseId = 1L; + when(courseRepository.findById(courseId)).thenReturn(Optional.empty()); + + assertThrows(BadRequestException.class, () -> courseReader.getCourse(courseId)); + } + + @DisplayName("getCoursesSliceInPlace 메서드가 Course의 Slice를 잘 반환하는지") + @Test + void getCoursesSliceInPlace_ReturnsSliceOfCourses() { + Long placeId = 1L; + Pageable pageable = mock(Pageable.class); + Slice expectedSlice = mock(Slice.class); + when(courseRepository.findBySpotPlaceId(placeId, pageable)).thenReturn(expectedSlice); + + Slice result = courseReader.getCoursesSliceInPlace(placeId, pageable); + + assertEquals(expectedSlice, result); + } + + @DisplayName("searchCourseByPlaceName 메서드가 Course의 Slice를 잘 반환하는지") + @Test + void searchCourseByPlaceName_ReturnsSliceOfCourses() { + String keyword = "keyword"; + Pageable pageable = mock(Pageable.class); + Slice expectedSlice = mock(Slice.class); + when(courseRepository.findByPlaceNameContaining(keyword, pageable)).thenReturn(expectedSlice); + + Slice result = courseReader.searchCourseByPlaceName(keyword, pageable); + + assertEquals(expectedSlice, result); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseSeriesFactoryImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseSeriesFactoryImplTest.java new file mode 100644 index 000000000..88fdc4684 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseSeriesFactoryImplTest.java @@ -0,0 +1,85 @@ +package kr.co.yigil.travel.infrastructure.course; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.course.CourseCommand.ModifyCourseRequest; +import kr.co.yigil.travel.domain.spot.SpotCommand.ModifySpotRequest; +import kr.co.yigil.travel.domain.spot.SpotReader; +import kr.co.yigil.travel.domain.spot.SpotSeriesFactory; +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; + +@ExtendWith(MockitoExtension.class) +public class CourseSeriesFactoryImplTest { + + @Mock + private SpotSeriesFactory spotSeriesFactory; + + @Mock + private SpotReader spotReader; + + @InjectMocks + private CourseSeriesFactoryImpl courseSeriesFactory; + + private Course course; + private Spot spot1, spot2; + private ModifyCourseRequest modifyCourseRequest; + + @BeforeEach + void setUp() { + spot1 = mock(Spot.class); + when(spot1.getId()).thenReturn(1L); + + spot2 = mock(Spot.class); + when(spot2.getId()).thenReturn(2L); + + course = mock(Course.class); + when(course.getSpots()).thenReturn(Arrays.asList(spot1, spot2)); + when(course.getDescription()).thenReturn("New Course Description"); + when(course.getRate()).thenReturn(4.5); + + ModifySpotRequest modifySpotRequest1 = new ModifySpotRequest(1L, 3.0, "수정수정", null, null); + ModifySpotRequest modifySpotRequest2 = new ModifySpotRequest(2L, 4.0, "수정수정2", null, null); + List modifySpotRequests = Arrays.asList(modifySpotRequest1, modifySpotRequest2); + List spotIdOrder = Arrays.asList(2L, 1L); + + modifyCourseRequest = new ModifyCourseRequest("New Course Description", 4.5, spotIdOrder, modifySpotRequests); + + when(spotReader.getSpot(1L)).thenReturn(spot1); + when(spotReader.getSpot(2L)).thenReturn(spot2); + when(spotSeriesFactory.modify(any(ModifySpotRequest.class), any(Spot.class))) + .thenAnswer(invocation -> invocation.getArgument(1)); + } + + @DisplayName("modify 메서드가 Course를 잘 업데이트 하는지") + @Test + void modify_CourseIsUpdatedCorrectly() { + Course modifiedCourse = courseSeriesFactory.modify(modifyCourseRequest, course); + + assertEquals("New Course Description", modifiedCourse.getDescription()); + assertEquals(4.5, modifiedCourse.getRate()); + + List updatedSpotIds = modifiedCourse.getSpots().stream().map(Spot::getId).collect( + Collectors.toList()); + assertEquals(Arrays.asList(1L, 2L), updatedSpotIds); + + verify(spotSeriesFactory, times(2)).modify(any(ModifySpotRequest.class), any(Spot.class)); + verify(spotReader, times(1)).getSpot(1L); + verify(spotReader, times(1)).getSpot(2L); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseSpotSeriesFactoryImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseSpotSeriesFactoryImplTest.java new file mode 100644 index 000000000..8c7da0338 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseSpotSeriesFactoryImplTest.java @@ -0,0 +1,84 @@ +package kr.co.yigil.travel.infrastructure.course; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.member.Member; +import kr.co.yigil.member.domain.MemberReader; +import kr.co.yigil.place.domain.Place; +import kr.co.yigil.place.domain.PlaceCacheStore; +import kr.co.yigil.place.domain.PlaceReader; +import kr.co.yigil.place.domain.PlaceStore; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequest; +import kr.co.yigil.travel.domain.course.CourseCommand.RegisterCourseRequestWithSpotInfo; +import kr.co.yigil.travel.domain.spot.SpotReader; +import kr.co.yigil.travel.domain.spot.SpotStore; +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; + +@ExtendWith(MockitoExtension.class) +public class CourseSpotSeriesFactoryImplTest { + + @Mock + private MemberReader memberReader; + @Mock + private PlaceReader placeReader; + @Mock + private SpotReader spotReader; + + @Mock + private PlaceStore placeStore; + @Mock + private SpotStore spotStore; + @Mock + private PlaceCacheStore placeCacheStore; + + @Mock + private FileUploader fileUploader; + + @InjectMocks + private CourseSpotSeriesFactoryImpl courseSpotSeriesFactory; + + private RegisterCourseRequest request; + private Member member; + private Place place; + private Spot spot; + + @BeforeEach + void setUp() { + member = mock(Member.class); + place = mock(Place.class); + spot = mock(Spot.class); + request = mock(RegisterCourseRequest.class); + } + + @DisplayName("store 메서드가 존재하는 spot에 대해서 Course Series를 잘 동작하는지") + @Test + void store_WithExistingSpots_RegistersSpotsSuccessfully() { + RegisterCourseRequestWithSpotInfo requestWithSpotInfo = mock(RegisterCourseRequestWithSpotInfo.class); // 필요한 메서드를 모킹 + when(requestWithSpotInfo.getSpotIds()).thenReturn(Arrays.asList(1L, 2L)); + when(spotReader.getSpots(anyList())).thenReturn(Collections.singletonList(spot)); + + List resultSpots = courseSpotSeriesFactory.store(requestWithSpotInfo, 1L); + + assertFalse(resultSpots.isEmpty()); + verify(spotReader).getSpots(anyList()); + verify(spot, times(1)).changeInCourse(); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseStoreImplTest.java new file mode 100644 index 000000000..75b1d8798 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/course/CourseStoreImplTest.java @@ -0,0 +1,47 @@ +package kr.co.yigil.travel.infrastructure.course; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.infrastructure.CourseRepository; +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; + +@ExtendWith(MockitoExtension.class) +public class CourseStoreImplTest { + + @Mock + private CourseRepository courseRepository; + + @InjectMocks + private CourseStoreImpl courseStore; + + @DisplayName("store 메서드가 잘 동작하는지") + @Test + void store_SavesAndReturnsCourse() { + Course initCourse = mock(Course.class); + when(courseRepository.save(initCourse)).thenReturn(initCourse); + + Course savedCourse = courseStore.store(initCourse); + + assertEquals(initCourse, savedCourse); + verify(courseRepository).save(initCourse); + } + + @DisplayName("remove 메서드가 잘 동작하는지") + @Test + void remove_DeletesCourse() { + Course course = mock(Course.class); + + courseStore.remove(course); + + verify(courseRepository).delete(course); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/spot/SpotReaderImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/spot/SpotReaderImplTest.java new file mode 100644 index 000000000..70bcb8971 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/spot/SpotReaderImplTest.java @@ -0,0 +1,105 @@ +package kr.co.yigil.travel.infrastructure.spot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import kr.co.yigil.global.exception.BadRequestException; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.infrastructure.SpotRepository; +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.Pageable; +import org.springframework.data.domain.Slice; + +@ExtendWith(MockitoExtension.class) +public class SpotReaderImplTest { + + @Mock + private SpotRepository spotRepository; + + @InjectMocks + private SpotReaderImpl spotReader; + + @DisplayName("getSpot 메서드가 Spot을 잘 반환하는지") + @Test + void getSpot_ReturnsSpot() { + Long spotId = 1L; + Spot expectedSpot = mock(Spot.class); + when(spotRepository.findById(spotId)).thenReturn(Optional.of(expectedSpot)); + + Spot result = spotReader.getSpot(spotId); + + assertEquals(expectedSpot, result); + } + + @DisplayName("getSpot 메서드가 SpotId가 유효하지 않을 때 예외를 잘 발생시키는지") + @Test + void getSpot_ThrowsBadRequestException_WhenNotFound() { + Long spotId = 1L; + when(spotRepository.findById(spotId)).thenReturn(Optional.empty()); + + assertThrows(BadRequestException.class, () -> spotReader.getSpot(spotId)); + } + + @DisplayName("findByPlaceIdAndMemberId 메서드가 Spot의 Optional 객체를 잘 반환하는지") + @Test + void findByPlaceIdAndMemberId_ReturnsOptionalOfSpot() { + Optional expected = mock(Optional.class); + when(spotRepository.findTopByPlaceIdAndMemberId(anyLong(), anyLong())).thenReturn(expected); + + Optional result = spotReader.findSpotByPlaceIdAndMemberId(1L, 1L); + + assertEquals(expected, result); + } + + @DisplayName("getSpots 메서드가 Spot List를 잘 반환하는지") + @Test + void getSpots_ReturnsListOfSpots() { + List spotIds = Arrays.asList(1L, 2L); + Spot spot1 = mock(Spot.class); + Spot spot2 = mock(Spot.class); + when(spotRepository.findById(1L)).thenReturn(Optional.of(spot1)); + when(spotRepository.findById(2L)).thenReturn(Optional.of(spot2)); + + List result = spotReader.getSpots(spotIds); + + assertEquals(2, result.size()); + assertTrue(result.containsAll(Arrays.asList(spot1, spot2))); + } + + @DisplayName("getSpotSliceInPlace 메서드가 Spot의 Slice를 잘 반환하는지") + @Test + void getSpotSliceInPlace_ReturnsSliceOfSpots() { + Long placeId = 1L; + Pageable pageable = mock(Pageable.class); + Slice expectedSlice = mock(Slice.class); + when(spotRepository.findAllByPlaceIdAndIsInCourseIsFalseAndIsPrivateIsFalse(placeId, pageable)).thenReturn(expectedSlice); + + Slice result = spotReader.getSpotSliceInPlace(placeId, pageable); + + assertEquals(expectedSlice, result); + } + + @DisplayName("getSpotCountInPlace 메서드가 count를 잘 반환하는지") + @Test + void getSpotCountInPlace_ReturnsCount() { + Long placeId = 1L; + int expectedCount = 5; + when(spotRepository.countByPlaceId(placeId)).thenReturn(expectedCount); + + int result = spotReader.getSpotCountInPlace(placeId); + + assertEquals(expectedCount, result); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/spot/SpotSeriesFactoryImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/spot/SpotSeriesFactoryImplTest.java new file mode 100644 index 000000000..19397b49a --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/spot/SpotSeriesFactoryImplTest.java @@ -0,0 +1,73 @@ +package kr.co.yigil.travel.infrastructure.spot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; +import kr.co.yigil.file.AttachFile; +import kr.co.yigil.file.FileReader; +import kr.co.yigil.file.FileUploader; +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.domain.spot.SpotCommand; +import kr.co.yigil.travel.domain.spot.SpotCommand.OriginalSpotImage; +import kr.co.yigil.travel.domain.spot.SpotCommand.UpdateSpotImage; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +@ExtendWith(MockitoExtension.class) +public class SpotSeriesFactoryImplTest { + + @Mock + private FileReader fileReader; + + @Mock + private FileUploader fileUploader; + + @InjectMocks + private SpotSeriesFactoryImpl spotSeriesFactory; + + @Test + void modify_UpdatesSpotSuccessfully() { + Spot spot = mock(Spot.class); + double newRate = 4.5; + String newDescription = "Updated description"; + List originalImages = Arrays.asList( + new SpotCommand.OriginalSpotImage("originalUrl1", 1), + new SpotCommand.OriginalSpotImage("originalUrl2", 0)); + List updatedImages = List.of( + new UpdateSpotImage( + new MockMultipartFile("newImage1", "newImage1.jpg", "image/jpeg", + new byte[10]), 2)); + + AttachFile attachFile = mock(AttachFile.class); + + when(fileReader.getFileByUrl(anyString())).thenReturn(attachFile); + when(fileUploader.upload(any())).thenReturn(attachFile); + + SpotCommand.ModifySpotRequest command = SpotCommand.ModifySpotRequest.builder() + .rate(newRate) + .description(newDescription) + .originalImages(originalImages) + .updatedImages(updatedImages) + .build(); + + Spot modifiedSpot = spotSeriesFactory.modify(command, spot); + + assertNotNull(modifiedSpot); + } + + +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/spot/SpotStoreImplTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/spot/SpotStoreImplTest.java new file mode 100644 index 000000000..f19d2a4b8 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/infrastructure/spot/SpotStoreImplTest.java @@ -0,0 +1,45 @@ +package kr.co.yigil.travel.infrastructure.spot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.co.yigil.travel.domain.Spot; +import kr.co.yigil.travel.infrastructure.SpotRepository; +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; + +@ExtendWith(MockitoExtension.class) +public class SpotStoreImplTest { + + @Mock + private SpotRepository spotRepository; + + @InjectMocks + private SpotStoreImpl spotStore; + + @DisplayName("store가 저장된 Spot을 잘 반환하는지") + @Test + void store_SavesAndReturnsSpot() { + Spot spot = mock(Spot.class); // 가정: Spot 객체를 적절히 초기화 + when(spotRepository.save(spot)).thenReturn(spot); + + Spot savedSpot = spotStore.store(spot); + + assertEquals(spot, savedSpot); + verify(spotRepository).save(spot); + } + + @DisplayName("remove 메서드가 Spot을 잘 삭제하는지") + @Test + void remove_DeleteSpot() { + Spot spot = mock(Spot.class); + spotStore.remove(spot); + verify(spotRepository).delete(spot); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/interfaces/controller/CourseApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/interfaces/controller/CourseApiControllerTest.java new file mode 100644 index 000000000..c23624cc9 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/interfaces/controller/CourseApiControllerTest.java @@ -0,0 +1,522 @@ +package kr.co.yigil.travel.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.multipart; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.application.CourseFacade; +import kr.co.yigil.travel.domain.Course; +import kr.co.yigil.travel.domain.course.CourseInfo; +import kr.co.yigil.travel.interfaces.dto.CourseDetailInfoDto; +import kr.co.yigil.travel.interfaces.dto.CourseDetailInfoDto.CourseSpotInfoDto; +import kr.co.yigil.travel.interfaces.dto.CourseDto; +import kr.co.yigil.travel.interfaces.dto.CourseInfoDto; +import kr.co.yigil.travel.interfaces.dto.mapper.CourseMapper; +import kr.co.yigil.travel.interfaces.dto.response.CourseSearchResponse; +import kr.co.yigil.travel.interfaces.dto.response.CoursesInPlaceResponse; +import kr.co.yigil.travel.interfaces.dto.response.MyCoursesResponse; +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.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.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(CourseApiController.class) +@AutoConfigureRestDocs +public class CourseApiControllerTest { + + @MockBean + private CourseFacade courseFacade; + + @MockBean + private CourseMapper courseMapper; + + private MockMvc mockMvc; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation)).build(); + } + + @DisplayName("getCoursesInPlace 메서드가 잘 동작하는지") + @Test + void getCoursesInPlace_ShouldReturnOk() throws Exception { + Slice mockSlice = mock(Slice.class); + CourseInfoDto courseInfo = new CourseInfoDto("images/static.img", "코스이름", "5.0", "3", + "2024-02-01", "images/owner.jpg", "코스 작성자"); + CoursesInPlaceResponse response = new CoursesInPlaceResponse(List.of(courseInfo), false); + + when(courseFacade.getCourseSliceInPlace(anyLong(), any(Pageable.class))).thenReturn( + mockSlice); + when(courseMapper.courseSliceToCourseInPlaceResponse(mockSlice)).thenReturn(response); + + mockMvc.perform(get("/api/v1/courses/place/{placeId}", 1L) + .param("page", "1") + .param("size", "5") + .param("sortBy", "created_at") + .param("sortOrder", "desc")) + .andExpect(status().isOk()) + .andDo(document( + "courses/get-courses-in-place", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("placeId").description("장소 아이디") + ), + queryParameters( + parameterWithName("page").description("현재 페이지 - default:1") + .optional(), + parameterWithName("size").description("페이지 크기 - default:5") + .optional(), + parameterWithName("sortBy").description( + "정렬 옵션 - created_at(디폴트값) / rate").optional(), + parameterWithName("sortOrder").description( + "정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순").optional() + ), + responseFields( + fieldWithPath("has_next").type(JsonFieldType.BOOLEAN) + .description("다음 페이지가 있는지 여부"), + subsectionWithPath("courses").description("course의 정보"), + fieldWithPath("courses[].map_static_image_file_url").type( + JsonFieldType.STRING) + .description("코스의 지도 정보를 나타내는 이미지 파일 경로"), + fieldWithPath("courses[].title").type(JsonFieldType.STRING) + .description("코스의 제목"), + fieldWithPath("courses[].rate").type(JsonFieldType.STRING) + .description("코스의 평점 정보"), + fieldWithPath("courses[].spot_count").type(JsonFieldType.STRING) + .description("코스 내부 장소의 개수"), + fieldWithPath("courses[].create_date").type(JsonFieldType.STRING) + .description("코스의 생성 일자"), + fieldWithPath("courses[].owner_profile_image_url").type( + JsonFieldType.STRING) + .description("코스 생성자의 프로필 이미지 경로"), + fieldWithPath("courses[].owner_nickname").type(JsonFieldType.STRING) + .description("코스 생성자의 닉네임 정보") + ) + )); + + verify(courseFacade).getCourseSliceInPlace(anyLong(), any(Pageable.class)); + } + + @DisplayName("registerCourse 메서드가 잘 동작하는지") + @Test + void registerCourse_ShouldReturnOk() throws Exception { + MockMultipartFile mapStaticImage = new MockMultipartFile("mapStatic", "mapStatic.png", + "image/png", "<>".getBytes()); + + MockMultipartFile image1 = new MockMultipartFile("image", "image.jpg", "image/jpeg", + "<>".getBytes()); + MockMultipartFile image2 = new MockMultipartFile("pic", "pic.jpg", "image/jpeg", + "<>".getBytes()); + + MockMultipartFile spotMapStaticImage = new MockMultipartFile("mapStatic", "mapStatic.png", + "image/png", "<>".getBytes()); + MockMultipartFile placeImage = new MockMultipartFile("placeImg", "placeImg.png", + "image/png", "<>".getBytes()); + + String requestBody = "{\"title\" : \"test\", \"description\" : \"test\", \"rate\" : 4.5, \"isPrivate\" : false, \"representativeSpotOrder\" : 1, \"lineStringJson\" : \"test\", \"spotRegisterRequests\" : [{\"pointJson\" : \"test\", \"title\" : \"test\", \"description\" : \"test\", \"rate\" : 4.5, \"placeName\" : \"test\", \"placeAddress\" : \"test\", \"placePointJson\" : \"test\"}]}"; + + mockMvc.perform(multipart("/api/v1/courses") + .file("mapStaticImageFile", mapStaticImage.getBytes()) + .file("spotRegisterRequests[0].files", image1.getBytes()) + .file("spotRegisterRequests[0].files", image2.getBytes()) + .file("spotRegisterRequests[0].mapStaticImageFile", spotMapStaticImage.getBytes()) + .file("spotRegisterRequests[0].placeImageFile", placeImage.getBytes()) + .contentType(MediaType.MULTIPART_FORM_DATA) + .content(requestBody)) + .andExpect(status().isOk()) + .andDo(document( + "courses/register-course", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("mapStaticImageFile").description( + "Course의 위치 정보를 나타내는 지도 이미지 파일"), + partWithName("spotRegisterRequests[0].files").description( + "스팟 관련 이미지 파일"), + partWithName( + "spotRegisterRequests[0].mapStaticImageFile").description( + "스팟의 위치 정보를 나타내는 지도 이미지 파일"), + partWithName("spotRegisterRequests[0].placeImageFile").description( + "스팟의 장소 이미지 파일") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING) + .description("코스의 제목"), + fieldWithPath("description").type(JsonFieldType.STRING) + .description("코스의 본문"), + fieldWithPath("rate").type(JsonFieldType.NUMBER) + .description("코스의 평점"), + fieldWithPath("isPrivate").type(JsonFieldType.BOOLEAN) + .description("코스의 공개 여부"), + fieldWithPath("representativeSpotOrder").type(JsonFieldType.NUMBER) + .description("코스의 대표 스팟 순서 번호"), + fieldWithPath("lineStringJson").type(JsonFieldType.STRING) + .description("코스의 라인 스트링 정보"), + subsectionWithPath("spotRegisterRequests").description( + "코스 내 스팟의 정보"), + fieldWithPath("spotRegisterRequests[].pointJson").type( + JsonFieldType.STRING).description("스팟의 포인트 정보"), + fieldWithPath("spotRegisterRequests[].title").type( + JsonFieldType.STRING).description("스팟의 제목"), + fieldWithPath("spotRegisterRequests[].description").type( + JsonFieldType.STRING).description("스팟의 본문"), + fieldWithPath("spotRegisterRequests[].rate").type( + JsonFieldType.NUMBER).description("스팟의 평점"), + fieldWithPath("spotRegisterRequests[].placeName").type( + JsonFieldType.STRING).description("스팟의 장소명"), + fieldWithPath("spotRegisterRequests[].placeAddress").type( + JsonFieldType.STRING).description("스팟의 장소 주소"), + fieldWithPath("spotRegisterRequests[].placePointJson").type( + JsonFieldType.STRING).description("스팟의 장소 포인트 정보") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답의 본문 메시지") + ) + )); + + verify(courseFacade).registerCourse(any(), anyLong()); + } + + @DisplayName("registerCourseWithoutSeries 메서드가 잘 동작하는지") + @Test + void registerCourseWithoutSeries_ShouldReturnOk() throws Exception { + MockMultipartFile mapStaticImage = new MockMultipartFile("mapStatic", "mapStatic.png", + "image/png", "<>".getBytes()); + + String requestBody = "{\"title\" : \"test\", \"description\" : \"test\", \"rate\" : 4.5, \"isPrivate\" : false, \"representativeSpotOrder\" : 1, \"lineStringJson\" : \"test\", \"spotIds\" : [1, 2]}"; + + mockMvc.perform(multipart("/api/v1/courses/only") + .file("mapStaticImageFile", mapStaticImage.getBytes()) + .contentType(MediaType.MULTIPART_FORM_DATA) + .content(requestBody)) + .andExpect(status().isOk()) + .andDo(document( + "courses/register-course-only", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("mapStaticImageFile").description( + "Course의 위치 정보를 나타내는 지도 이미지 파일") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING) + .description("코스의 제목"), + fieldWithPath("description").type(JsonFieldType.STRING) + .description("코스의 본문"), + fieldWithPath("rate").type(JsonFieldType.NUMBER) + .description("코스의 평점"), + fieldWithPath("isPrivate").type(JsonFieldType.BOOLEAN) + .description("코스의 공개 여부"), + fieldWithPath("representativeSpotOrder").type(JsonFieldType.NUMBER) + .description("코스의 대표 스팟 순서 번호"), + fieldWithPath("lineStringJson").type(JsonFieldType.STRING) + .description("코스의 라인 스트링 정보"), + fieldWithPath("spotIds").type(JsonFieldType.ARRAY) + .description("코스 내 스팟의 아이디 배열") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답의 본문 메시지") + ) + )); + + verify(courseFacade).registerCourseWithoutSeries(any(), anyLong()); + } + + @DisplayName("retrieveCourse 메서드가 잘 동작하는지") + @Test + void retrieveCourse_ShouldReturnOk() throws Exception { + CourseInfo.Main mockInfo = mock(CourseInfo.Main.class); + CourseSpotInfoDto spotInfo = new CourseSpotInfoDto("1", "장소명", + List.of("images/spot.jpg", "images/spotted.png"), "4.5", "스팟 본문", "2024-02-01"); + CourseDetailInfoDto courseInfo = new CourseDetailInfoDto("최고의 코스", "4.5", + "images/static.png", "코스의 본문", List.of(spotInfo)); + + when(courseFacade.retrieveCourseInfo(anyLong())).thenReturn(mockInfo); + when(courseMapper.toCourseDetailInfoDto(mockInfo)).thenReturn(courseInfo); + + mockMvc.perform(get("/api/v1/courses/{courseId}", 1L)) + .andExpect(status().isOk()) + .andDo(document( + "courses/retrieve-course", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("courseId").description("코스 아이디") + ), + responseFields( + fieldWithPath("title").type(JsonFieldType.STRING) + .description("코스의 제목"), + fieldWithPath("rate").type(JsonFieldType.STRING) + .description("코스의 평점"), + fieldWithPath("map_static_image_url").type(JsonFieldType.STRING) + .description("코스의 위치를 나타내는 지도 이미지 경로"), + fieldWithPath("description").type(JsonFieldType.STRING) + .description("코스의 본문"), + subsectionWithPath("spots").description("코스 내 스팟의 정보"), + fieldWithPath("spots[].order").type(JsonFieldType.STRING) + .description("코스 내 현재 스팟의 순서"), + fieldWithPath("spots[].place_name").type(JsonFieldType.STRING) + .description("코스 내 스팟의 장소명"), + fieldWithPath("spots[].image_url_list").type(JsonFieldType.ARRAY) + .description("코스 내 스팟 관련 이미지의 경로 배열"), + fieldWithPath("spots[].rate").type(JsonFieldType.STRING) + .description("코스 내 스팟의 평점 정보"), + fieldWithPath("spots[].description").type(JsonFieldType.STRING) + .description("코스 내 스팟의 본문 정보"), + fieldWithPath("spots[].create_date").type(JsonFieldType.STRING) + .description("코스 내 스팟의 생성 일자") + ) + )); + + verify(courseFacade).retrieveCourseInfo(anyLong()); + } + + @DisplayName("updateCourse 메서드가 잘 동작하는지") + @Test + void updateCourse_ShouldReturnOk() throws Exception { + MockMultipartFile image1 = new MockMultipartFile("image", "image.jpg", "image/jpeg", + "<>".getBytes()); + + String requestBody = "{\"description\" : \"test\", \"rate\" : 4.5, \"spotIdOrder\" : [1, 2], \"courseSpotUpdateRequests\" : [{\"id\" : 1, \"description\" : \"test\", \"rate\" : 4.5, \"originalSpotImages\" : [{\"imageUrl\" : \"images/spot.jpg\", \"index\" : 1}], \"updateSpotImages\" : [{\"index\" : 1}]}]}"; + + mockMvc.perform(multipart("/api/v1/courses/{courseId}", 1L) + .file("courseSpotUpdateRequests[0].updateSpotImages[0].imageFile", + image1.getBytes()) + .contentType(MediaType.MULTIPART_FORM_DATA) + .content(requestBody)) + .andExpect(status().isOk()) + .andDo(document( + "courses/update-course", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("courseId").description("코스 아이디") + ), + requestParts( + partWithName( + "courseSpotUpdateRequests[0].updateSpotImages[0].imageFile").description( + "스팟 관련 이미지 파일") + ), + requestFields( + fieldWithPath("description").type(JsonFieldType.STRING) + .description("코스의 본문"), + fieldWithPath("rate").type(JsonFieldType.NUMBER) + .description("코스의 평점"), + fieldWithPath("spotIdOrder").type(JsonFieldType.ARRAY) + .description("코스 내 스팟의 아이디 배열"), + subsectionWithPath("courseSpotUpdateRequests").description( + "코스 내 스팟의 정보"), + fieldWithPath("courseSpotUpdateRequests[].id").type( + JsonFieldType.NUMBER).description("스팟의 아이디"), + fieldWithPath("courseSpotUpdateRequests[].description").type( + JsonFieldType.STRING).description("스팟의 본문"), + fieldWithPath("courseSpotUpdateRequests[].rate").type( + JsonFieldType.NUMBER).description("스팟의 평점"), + fieldWithPath("courseSpotUpdateRequests[].originalSpotImages").type( + JsonFieldType.ARRAY).description("스팟의 기존 이미지 정보"), + fieldWithPath("courseSpotUpdateRequests[].updateSpotImages").type( + JsonFieldType.ARRAY).description("스팟의 업데이트 이미지 정보"), + fieldWithPath( + "courseSpotUpdateRequests[].originalSpotImages[].imageUrl").type( + JsonFieldType.STRING).description("스팟의 기존 이미지 경로"), + fieldWithPath( + "courseSpotUpdateRequests[].originalSpotImages[].index").type( + JsonFieldType.NUMBER).description("스팟의 기존 이미지 인덱스"), + fieldWithPath( + "courseSpotUpdateRequests[].updateSpotImages[].index").type( + JsonFieldType.NUMBER).description("스팟의 업데이트 이미지 인덱스") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답의 본문 메시지") + ) + )); + + verify(courseFacade).modifyCourse(any(), anyLong(), anyLong()); + } + + @DisplayName("deleteCourse 메서드가 잘 동작하는지") + @Test + void deleteCourse_ShouldReturnOk() throws Exception { + mockMvc.perform(delete("/api/v1/courses/{courseId}", 1L)) + .andExpect(status().isOk()) + .andDo(document( + "courses/delete-course", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("courseId").description("코스 아이디") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답의 본문 메시지") + ) + )); + + verify(courseFacade).deleteCourse(anyLong(), anyLong()); + } + + + @DisplayName("내가 작성한 코스 목록 조회가 잘 되는지") + @Test + void getMyCourseInfo_ShouldReturnOk() throws Exception { + + MyCoursesResponse.CourseInfo courseInfo = MyCoursesResponse.CourseInfo.builder() + .courseId(1L) + .title("test course") + .rate(4.5) + .spotCount(10) + .createdDate("2024-01-01") + .mapStaticImageUrl("images/map.jpg") + .isPrivate(false) + .build(); + + MyCoursesResponse response = MyCoursesResponse.builder() + .content(List.of(courseInfo)) + .totalPages(1) + .build(); + + when(courseFacade.getMemberCoursesInfo(anyLong(), any(PageRequest.class), + any(Selected.class))).thenReturn(mock(CourseInfo.MyCoursesResponse.class)); + when(courseMapper.of(any(CourseInfo.MyCoursesResponse.class))).thenReturn(response); + + mockMvc.perform(get("/api/v1/courses/my") + .param("page", "1") + .param("size", "5") + .param("sortBy", "created_at") + .param("sortOrder", "desc") + .param("selected", "public") + ) + .andExpect(status().isOk()) + .andDo(document( + "courses/get-my-course-list", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("현재 페이지 - default:1") + .optional(), + parameterWithName("size").description("페이지 크기 - default:5") + .optional(), + parameterWithName("sortBy").description( + "정렬 옵션 - createdAt(디폴트값) / rate").optional(), + parameterWithName("sortOrder").description( + "정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순").optional(), + parameterWithName("selected").description( + "필터 기능 - all(디폴트값) 전체공개 / private 비공개").optional() + ), + responseFields( + fieldWithPath("content[].course_id").description("코스 ID"), + fieldWithPath("content[].title").description("코스 제목"), + fieldWithPath("content[].rate").description("코스 평점"), + fieldWithPath("content[].spot_count").description("코스 포함 장소 수"), + fieldWithPath("content[].created_date").description("코스 생성일"), + fieldWithPath("content[].map_static_image_url").description( + "코스 지도 이미지 URL"), + fieldWithPath("content[].is_private").description("공개여부"), + fieldWithPath("total_pages").description("총 페이지 수") + ) + )); + verify(courseFacade).getMemberCoursesInfo(anyLong(), any(PageRequest.class), + any(Selected.class)); + } + + @DisplayName("searchCourseByPlaceName 메서드가 잘 동작하는지") + @Test + void searchCourseByPlaceName_ShouldReturnOk() throws Exception { + String keyword = "keyword"; + CourseInfo.Slice mockSlice = mock(CourseInfo.Slice.class); + CourseDto dto = new CourseDto(1L, "title", "mapStatic.jpg", "profile.png", "nickname", 3, 4.5, false, + LocalDateTime.now()); + CourseSearchResponse response = new CourseSearchResponse(List.of(dto), false); + + when(courseFacade.searchCourseByPlaceName(eq(keyword), any(Accessor.class), any(Pageable.class))).thenReturn(mockSlice); + when(courseMapper.toCourseSearchResponse(mockSlice)).thenReturn(response); + + mockMvc.perform(get("/api/v1/courses/search") + .param("keyword", keyword) + .param("page", "1") + .param("size", "5") + .param("sortBy", "created_at") + .param("sortOrder", "desc") + ) + .andExpect(status().isOk()) + .andDo(document( + "courses/search-course-by-place-name", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("keyword").description("검색 키워드"), + parameterWithName("page").description("현재 페이지 - default:1") + .optional(), + parameterWithName("size").description("페이지 크기 - default:5") + .optional(), + parameterWithName("sortBy").description( + "정렬 옵션 - created_at(디폴트값) / rate / name").optional(), + parameterWithName("sortOrder").description( + "정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순").optional() + ), + responseFields( + fieldWithPath("has_next").type(JsonFieldType.BOOLEAN).description("다음 페이지가 있는지 여부"), + subsectionWithPath("courses").description("course의 정보"), + fieldWithPath("courses[].id").type(JsonFieldType.NUMBER).description("코스 ID"), + fieldWithPath("courses[].title").type(JsonFieldType.STRING).description("코스 제목"), + fieldWithPath("courses[].map_static_image_url").type(JsonFieldType.STRING).description( + "코스 지도 이미지 URL"), + fieldWithPath("courses[].owner_profile_image_url").type(JsonFieldType.STRING).description( + "코스 작성자 프로필 이미지 URL"), + fieldWithPath("courses[].owner_nickname").type(JsonFieldType.STRING).description("코스 작성자 닉네임"), + fieldWithPath("courses[].spot_count").type(JsonFieldType.NUMBER).description("코스 포함 장소 수"), + fieldWithPath("courses[].rate").type(JsonFieldType.NUMBER).description("코스 평점"), + fieldWithPath("courses[].liked").type(JsonFieldType.BOOLEAN).description("코스 좋아요 여부"), + fieldWithPath("courses[].create_date").type(JsonFieldType.STRING).description("코스 생성일") + ) + )); + + + } +} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/interfaces/controller/SpotApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/interfaces/controller/SpotApiControllerTest.java new file mode 100644 index 000000000..8c07c8ebc --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/interfaces/controller/SpotApiControllerTest.java @@ -0,0 +1,378 @@ +package kr.co.yigil.travel.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.multipart; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import kr.co.yigil.auth.domain.Accessor; +import kr.co.yigil.global.Selected; +import kr.co.yigil.travel.application.SpotFacade; +import kr.co.yigil.travel.domain.spot.SpotInfo; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpot; +import kr.co.yigil.travel.domain.spot.SpotInfo.MySpotsResponse; +import kr.co.yigil.travel.domain.spot.SpotInfo.Slice; +import kr.co.yigil.travel.interfaces.dto.SpotDetailInfoDto; +import kr.co.yigil.travel.interfaces.dto.SpotInfoDto; +import kr.co.yigil.travel.interfaces.dto.mapper.SpotMapper; +import kr.co.yigil.travel.interfaces.dto.response.MySpotInPlaceResponse; +import kr.co.yigil.travel.interfaces.dto.response.MySpotsResponseDto; +import kr.co.yigil.travel.interfaces.dto.response.SpotsInPlaceResponse; +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.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.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(SpotApiController.class) +@AutoConfigureRestDocs +class SpotApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private SpotFacade spotFacade; + + @MockBean + private SpotMapper spotMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation)).build(); + } + + @DisplayName("getSpotsInPlace가 잘 동작하는지") + @Test + void getSpotsInPlace_ShouldReturnOk() throws Exception { + SpotInfo.Slice mockSlice = mock(Slice.class); + SpotInfoDto spotInfo = new SpotInfoDto(1L, List.of("images/image.png", "images/photo.jpeg"), + "설명", "images/profile.jpg", "오너 닉네임", "4.5", "2024-02-01", true); + SpotsInPlaceResponse response = new SpotsInPlaceResponse(List.of(spotInfo), true); + + when(spotFacade.getSpotSliceInPlace(anyLong(), any(Accessor.class), any(Pageable.class))).thenReturn(mockSlice); + when(spotMapper.toSpotsInPlaceResponse(mockSlice)).thenReturn(response); + + mockMvc.perform(get("/api/v1/spots/place/{placeId}", 1L) + .param("page", "1") + .param("size", "5") + .param("sortBy", "created_at") + .param("sortOrder", "desc")) + .andExpect(status().isOk()) + .andDo(document( + "spots/get-spots-in-place", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("placeId").description("장소 아이디") + ), + queryParameters( + parameterWithName("page").description("현재 페이지 - default:1").optional(), + parameterWithName("size").description("페이지 크기 - default:5").optional(), + parameterWithName("sortBy").description("정렬 옵션 - created_at(디폴트값) / rate") + .optional(), + parameterWithName("sortOrder").description("정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순") + .optional() + ), + responseFields( + fieldWithPath("has_next").type(JsonFieldType.BOOLEAN) + .description("다음 페이지가 있는지 여부"), + subsectionWithPath("spots").description("spot의 정보"), + fieldWithPath("spots[].id").type(JsonFieldType.NUMBER).description("Spot의 고유 아이디"), + fieldWithPath("spots[].image_url_list").type(JsonFieldType.ARRAY).description("imageUrl의 List"), + fieldWithPath("spots[].description").type(JsonFieldType.STRING).description("Spot의 설명"), + fieldWithPath("spots[].owner_profile_image_url").type(JsonFieldType.STRING) + .description("Spot 등록 사용자의 프로필 이미지 Url"), + fieldWithPath("spots[].owner_nickname").type(JsonFieldType.STRING) + .description("Spot 등록 사용자의 닉네임"), + fieldWithPath("spots[].rate").type(JsonFieldType.STRING) + .description("Spot의 평점"), + fieldWithPath("spots[].create_date").type(JsonFieldType.STRING) + .description("Spot의 생성일시"), + fieldWithPath("spots[].liked").type(JsonFieldType.BOOLEAN) + .description("로그인한 사용자의 좋아요 여부") + ) + )); + + verify(spotFacade).getSpotSliceInPlace(anyLong(), any(Accessor.class), any(Pageable.class)); + + } + @DisplayName("getMySpotInPlace 메서드가 잘 동작하는지") + @Test + void getMySpotInPlace_ShouldReturnOk() throws Exception { + MySpot mockInfo = mock(MySpot.class); + MySpotInPlaceResponse mockResponse = new MySpotInPlaceResponse(true, "4.5", + List.of("images/image.jpg", "images/thumb.png"), "2024-02-05", "내가 쓴 리뷰리뷰리뷰"); + + when(spotFacade.retrieveMySpotInfoInPlace(anyLong(), anyLong())).thenReturn(mockInfo); + when(spotMapper.toMySpotInPlaceResponse(mockInfo)).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/spots/place/{placeId}/me", 1L)) + .andExpect(status().isOk()) + .andDo(document( + "spots/get-my-spot-in-place", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("placeId").description("장소 아이디") + ), + responseFields( + fieldWithPath("exists").type(JsonFieldType.BOOLEAN).description("스팟이 존재하는지 여부"), + fieldWithPath("rate").type(JsonFieldType.STRING).description("스팟의 평점 정보"), + fieldWithPath("image_urls").type(JsonFieldType.ARRAY) + .description("스팟 관련 이미지의 url 배열"), + fieldWithPath("create_date").type(JsonFieldType.STRING) + .description("스팟의 생성 일자"), + fieldWithPath("description").type(JsonFieldType.STRING).description("스팟의 본문") + ) + )); + + verify(spotFacade).retrieveMySpotInfoInPlace(anyLong(), anyLong()); + } + + @DisplayName("registerSpot 메서드가 잘 동작하는지") + @Test + void registerSpot_ShouldReturnOk() throws Exception { + MockMultipartFile image1 = new MockMultipartFile("image", "image.jpg", "image/jpeg", + "<>".getBytes()); + MockMultipartFile image2 = new MockMultipartFile("pic", "pic.jpg", "image/jpeg", + "<>".getBytes()); + MockMultipartFile mapStaticImage = new MockMultipartFile("mapStatic", "mapStatic.png", + "image/png", "<>".getBytes()); + MockMultipartFile placeImage = new MockMultipartFile("placeImg", "placeImg.png", + "image/png", "<>".getBytes()); + + String requestBody = "{\"pointJson\": \"{ \\\"type\\\" : \\\"Point\\\", \\\"coordinates\\\": [ 555, 555 ] }\", \"title\": \"스팟 타이틀\", \"description\": \"스팟 본문\", \"rate\": 5.0, \"placeName\": \"장소 타이틀\", \"placeAddress\": \"장소구 장소면 장소리\", \"placePointJson\": \"{ \\\"type\\\" : \\\"Point\\\", \\\"coordinates\\\": [ 555, 555 ] }\"}"; + + mockMvc.perform(multipart("/api/v1/spots") + .file("files", image1.getBytes()) + .file("files", image2.getBytes()) + .file("mapStaticImageFile", mapStaticImage.getBytes()) + .file("placeImageFile", placeImage.getBytes()) + .contentType(MediaType.MULTIPART_FORM_DATA) + .content(requestBody) + ).andDo(document( + "spots/register-spot", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("files").description("Spot의 이미지 파일 (다중파일)"), + partWithName("mapStaticImageFile").description("Spot의 장소를 나타내는 지도 이미지 파일(필수x)"), + partWithName("placeImageFile").description("Spot의 장소를 나타내는 썸네일 이미지 파일(필수x)") + ), + requestFields( + fieldWithPath("pointJson").type(JsonFieldType.STRING) + .description("스팟의 위치를 나타내는 geojson"), + fieldWithPath("title").type(JsonFieldType.STRING).description("스팟의 제목"), + fieldWithPath("description").type(JsonFieldType.STRING).description("스팟의 본문"), + fieldWithPath("rate").type(JsonFieldType.NUMBER).description("스팟 관련 평점 정보"), + fieldWithPath("placeName").type(JsonFieldType.STRING).description("스팟 관련 장소 명"), + fieldWithPath("placeAddress").type(JsonFieldType.STRING).description("스팟 관련 장소 주소"), + fieldWithPath("placePointJson").type(JsonFieldType.STRING) + .description("스팟 관련 장소의 위치를 나타내는 geojson") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답의 본문 메시지") + ) + )); + + verify(spotFacade).registerSpot(any(), anyLong()); + } + + @DisplayName("retrieveSpot 메서드가 잘 동작하는지") + @Test + void retrieveSpot_ShouldReturnOk() throws Exception { + SpotInfo.Main mockInfo = mock(SpotInfo.Main.class); + SpotDetailInfoDto mockResponse = new SpotDetailInfoDto("장소명", "3.0", "장소시 장소구 장소동", + "images/mapstatic.png", List.of("images/spot.png", "images/spot.jpeg"), "2024-02-01", + "스팟 설명"); + when(spotFacade.retrieveSpotInfo(anyLong())).thenReturn(mockInfo); + when(spotMapper.toSpotDetailInfoDto(mockInfo)).thenReturn(mockResponse); + mockMvc.perform(get("/api/v1/spots/{spotId}", 1L)) + .andExpect(status().isOk()) + .andDo(document( + "spots/retrieve-spot", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("spotId").description("스팟 아이디") + ), + responseFields( + fieldWithPath("place_name").type(JsonFieldType.STRING) + .description("스팟 관련 장소 명"), + fieldWithPath("rate").type(JsonFieldType.STRING).description("스팟의 평점 정보"), + fieldWithPath("place_address").type(JsonFieldType.STRING) + .description("스팟 관련 장소의 주소"), + fieldWithPath("map_static_image_file_url").type(JsonFieldType.STRING) + .description("스팟의 위치를 나타내는 이미지 파일의 상대경로"), + fieldWithPath("image_urls").type(JsonFieldType.ARRAY) + .description("스팟 관련 이미지의 상대 경로 배열"), + fieldWithPath("create_date").type(JsonFieldType.STRING) + .description("스팟의 생성 일자"), + fieldWithPath("description").type(JsonFieldType.STRING).description("스팟의 본문 정보") + ) + )); + + verify(spotFacade).retrieveSpotInfo(anyLong()); + } + + @DisplayName("updateSpot 메서드가 잘 동작하는지") + @Test + void updateSpot_ShouldReturnOk() throws Exception { + + MockMultipartFile image1 = new MockMultipartFile("image", "image.jpg", "image/jpeg", + "<>".getBytes()); + + String requestBody = "{\"id\" : 1, \"description\" : \"스팟 설명\", \"rate\" : 4.5, \"originalSpotImages\" : [ { \"imageUrl\" : \"images/spot.jpg\", \"index\" : 0 } ], \"updateSpotImages\" : [ { \"index\" : 0 } ]}"; + + mockMvc.perform(multipart("/api/v1/spots/{spotId}", 1L) + .file("updateSpotImages[0].imageFile", image1.getBytes()) + .contentType(MediaType.MULTIPART_FORM_DATA) + .content(requestBody)) + .andExpect(status().isOk()) + .andDo(document( + "spots/update-spot", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("spotId").description("스팟 아이디") + ), + requestParts( + partWithName("updateSpotImages[0].imageFile").description( + "업데이트 할 스팟의 새로운 이미지 파일") + ), + requestFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("스팟 아이디"), + fieldWithPath("description").type(JsonFieldType.STRING) + .description("스팟의 본문 정보"), + fieldWithPath("rate").type(JsonFieldType.NUMBER).description("스팟의 평점 정보"), + subsectionWithPath("originalSpotImages").description("기존 스팟 이미지 정보"), + fieldWithPath("originalSpotImages[].imageUrl").type(JsonFieldType.STRING) + .description("기존 스팟 이미지의 url"), + fieldWithPath("originalSpotImages[].index").type(JsonFieldType.NUMBER) + .description("기존 스팟 이미지의 index"), + subsectionWithPath("updateSpotImages").description("업데이트 할 스팟 이미지 정보"), + fieldWithPath("updateSpotImages[].index").type(JsonFieldType.NUMBER) + .description("업데이트 할 스팟 이미지의 index") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답의 본문 메시지") + ) + )); + + verify(spotFacade).modifySpot(any(), anyLong(), anyLong()); + } + + @DisplayName("deleteSpot 메서드가 잘 동작하는지") + @Test + void deleteSpot_ShouldReturnOk() throws Exception { + mockMvc.perform(delete("/api/v1/spots/{spotId}", 1L)) + .andExpect(status().isOk()) + .andDo(document( + "spots/delete-spot", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("spotId").description("스팟 아이디") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답의 본문 메시지") + ) + )); + + verify(spotFacade).deleteSpot(anyLong(), anyLong()); + } + + + @DisplayName("내가 작성한 장소 목록 조회가 잘 되는지") + @Test + void getMySpotInfo_ShouldReturnOk() throws Exception { + + MySpotsResponseDto.SpotInfo spotInfo = MySpotsResponseDto.SpotInfo.builder() + .spotId(1L) + .title("test course") + .rate(4.5) + .imageUrl("images/map.jpg") + .createdDate("2024-01-01") + .isPrivate(false) + .build(); + + MySpotsResponseDto response = MySpotsResponseDto.builder() + .content(List.of(spotInfo)) + .totalPages(1) + .build(); + + when(spotFacade.getMemberSpotsInfo(anyLong(), any(Selected.class), + any(PageRequest.class))).thenReturn(mock(MySpotsResponse.class)); + when(spotMapper.of(any(SpotInfo.MySpotsResponse.class))).thenReturn(response); + + mockMvc.perform(get("/api/v1/spots/my") + .param("page", "1") + .param("size", "5") + .param("sortBy", "created_at") + .param("sortOrder", "desc") + .param("selected", "public") + ) + .andExpect(status().isOk()) + .andDo(document( + "spots/get-my-spot-list", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("현재 페이지 - default:1").optional(), + parameterWithName("size").description("페이지 크기 - default:5").optional(), + parameterWithName("sortBy").description("정렬 옵션 - createdAt(디폴트값) / rate") + .optional(), + parameterWithName("sortOrder").description("정렬 순서 - desc(디폴트값) 내림차순 / asc 오름차순") + .optional(), + parameterWithName("selected").description( + "필터 기능 - all(디폴트값) 전체공개 / private 비공개").optional() + ), + responseFields( + fieldWithPath("content[].spot_id").description("장소 ID"), + fieldWithPath("content[].title").description("장소 제목"), + fieldWithPath("content[].rate").description("장소 평점"), + fieldWithPath("content[].image_url").description("장소 이미지 URL"), + fieldWithPath("content[].created_date").description("장소 생성일"), + fieldWithPath("content[].is_private").description("공개여부"), + fieldWithPath("total_pages").description("총 페이지 수") + )) + ); + + verify(spotFacade).getMemberSpotsInfo(anyLong(), any(Selected.class), any(PageRequest.class)); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/interfaces/controller/TravelApiControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/interfaces/controller/TravelApiControllerTest.java new file mode 100644 index 000000000..c24762090 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/interfaces/controller/TravelApiControllerTest.java @@ -0,0 +1,129 @@ +package kr.co.yigil.travel.interfaces.controller; + +import static kr.co.yigil.RestDocumentUtils.getDocumentRequest; +import static kr.co.yigil.RestDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import kr.co.yigil.travel.application.TravelFacade; +import kr.co.yigil.travel.interfaces.dto.mapper.TravelMapper; +import kr.co.yigil.travel.interfaces.dto.response.TravelsVisibilityChangeResponse; +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.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.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) +@WebMvcTest(TravelApiController.class) +@AutoConfigureRestDocs +public class TravelApiControllerTest { + + private MockMvc mockMvc; + + @MockBean + private TravelFacade travelFacade; + + @MockBean + private TravelMapper travelMapper; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation)).build(); + } + + @DisplayName("changeOnPublicTravel 메서드가 잘 동작하는지") + @Test + void changeOnPublicTravel_ReturnsOk() throws Exception { + + mockMvc.perform(post("/api/v1/travels/change-on-public") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"travel_id\":1}")) + .andExpect(status().isOk()) + .andDo(document( + "travels/change-on-public", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("travel_id").type(JsonFieldType.NUMBER).description("travel의 id") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답의 본문 메시지") + ) + )); + + verify(travelFacade).changeOnPublicTravel(anyLong(), anyLong()); + } + + @DisplayName("changeOnPrivateTravel 메서드가 잘 동작하는지") + @Test + void changeOnPrivateTravel_ReturnsOk() throws Exception { + + mockMvc.perform(post("/api/v1/travels/change-on-private") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"travel_id\":2}")) + .andExpect(status().isOk()) + .andDo(document( + "travels/change-on-private", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("travel_id").type(JsonFieldType.NUMBER).description("travel의 id") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답의 본문 메시지") + ) + )); + + verify(travelFacade).changeOnPrivateTravel(anyLong(), anyLong()); + } + + + @DisplayName("스팟 및 코스의 공개 여부 설정이 잘 되는지") + @Test + void setTravelsVisibility_ShouldReturnOk() throws Exception { + + TravelsVisibilityChangeResponse response = new TravelsVisibilityChangeResponse( + "리뷰 공개 상태 변경 완료"); + + when(travelMapper.of(anyString())).thenReturn(response); + + mockMvc.perform(post("/api/v1/travels/change-visibility") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"visibility\": \"public\"}") + .content("{\"travel_ids\": [1], \"is_private\": false}") + ) + .andExpect(status().isOk()) + .andDo(document( + "travels/set-travels-visibility", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + fieldWithPath("message").description("메시지") + ) + )); + verify(travelFacade).setTravelsVisibility(anyLong(), any()); + } +} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/presentation/CourseControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/presentation/CourseControllerTest.java deleted file mode 100644 index 19db08849..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/travel/presentation/CourseControllerTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package kr.co.yigil.travel.presentation; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.travel.application.CourseService; -import kr.co.yigil.travel.dto.request.CourseCreateRequest; -import kr.co.yigil.travel.dto.request.CourseUpdateRequest; -import kr.co.yigil.travel.dto.response.CourseCreateResponse; -import kr.co.yigil.travel.dto.response.CourseFindResponse; -import kr.co.yigil.travel.dto.response.CourseUpdateResponse; -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.junit.jupiter.MockitoExtension; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@ExtendWith({MockitoExtension.class, SpringExtension.class}) -@WebMvcTest(CourseController.class) -class CourseControllerTest { - - private MockMvc mockMvc; - - @MockBean - private CourseService courseService; - - @InjectMocks - private CourseController courseController; - - @BeforeEach - public void setup(WebApplicationContext webApplicationContext) { - this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } - - @DisplayName("Course 생성 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenCreateCourse_thenReturnsOkAndCourseCreateResponse() throws Exception { - CourseCreateResponse mockResponse = new CourseCreateResponse(); - Accessor accessor = Accessor.member(1L); - - given(courseService.createCourse(anyLong(), any(CourseCreateRequest.class))).willReturn(mockResponse); - - String jsonContent = "{" - + "\"title\":\"Sample Title\"," - + "\"representativeSpotOrder\":1," - + "\"spotIds\":[1,2,3]," - + "\"lineStringJson\":\"{\\\"type\\\": \\\"LineString\\\", \\\"coordinates\\\": [[1, 2], [3, 4]]}\"" - + "}"; - - mockMvc.perform(post("/api/v1/courses") - .contentType(MediaType.APPLICATION_JSON) - .content(jsonContent) - .sessionAttr("memberId", accessor.getMemberId())) - .andExpect(status().isOk()); - } - - @DisplayName("Course 조회 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenFindCourse_thenReturnsOkAndCourseFindResponse() throws Exception { - CourseFindResponse mockResponse = new CourseFindResponse(); - Long postId = 1L; - - given(courseService.findCourse(postId)).willReturn(mockResponse); - - mockMvc.perform(get("/api/v1/courses/1") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @DisplayName("Course 업데이트 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenUpdateCourse_thenReturnsOkAndCourseUpdateResponse() throws Exception { - CourseUpdateResponse mockResponse = new CourseUpdateResponse(); - CourseUpdateRequest mockRequest = new CourseUpdateRequest(); - Accessor accessor = Accessor.member(1L); - given(courseService.updateCourse(anyLong(), anyLong(), any(CourseUpdateRequest.class))).willReturn(mockResponse); - - String jsonContent = "{" - + "\"title\":\"Updated Title\"," - + "\"representativeSpotOrder\":2," - + "\"spotIds\":[4,5,6]," - + "\"lineStringJson\":\"{\\\"type\\\": \\\"LineString\\\", \\\"coordinates\\\": [[5, 6], [7, 8]]}\"" - + "}"; - - mockMvc.perform(put("/api/v1/courses/1") - .contentType(MediaType.APPLICATION_JSON) - .content(jsonContent) - .sessionAttr("memberId", accessor.getMemberId())) - .andExpect(status().isOk()); - } -} diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/presentation/SpotControllerTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/presentation/SpotControllerTest.java deleted file mode 100644 index cb01da883..000000000 --- a/backend/yigil-api/src/test/java/kr/co/yigil/travel/presentation/SpotControllerTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package kr.co.yigil.travel.presentation; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import kr.co.yigil.auth.domain.Accessor; -import kr.co.yigil.travel.application.SpotService; -import kr.co.yigil.travel.dto.request.SpotCreateRequest; -import kr.co.yigil.travel.dto.request.SpotUpdateRequest; -import kr.co.yigil.travel.dto.response.SpotCreateResponse; -import kr.co.yigil.travel.dto.response.SpotFindResponse; -import kr.co.yigil.travel.dto.response.SpotUpdateResponse; -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.ArgumentMatchers; -import org.mockito.InjectMocks; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@ExtendWith(MockitoExtension.class) -@WebMvcTest(SpotController.class) -class SpotControllerTest { - - private MockMvc mockMvc; - - @MockBean - private SpotService spotService; - - @InjectMocks - private SpotController spotController; - - @BeforeEach - public void setup(WebApplicationContext webApplicationContext){ - this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); - } - - @DisplayName("spot 게시글 생성 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenCreateSpotPost_thenReturns200AndSpotCreateResponse() throws Exception { - SpotCreateResponse mockResponse = new SpotCreateResponse(); - Accessor accessor = Accessor.member(1L); - - given(spotService.createSpot(anyLong(), any(SpotCreateRequest.class))).willReturn(mockResponse); - MockMultipartFile imageFile = new MockMultipartFile("file", "filename.jpg", "image/jpeg", new byte[10]); - MockMultipartFile videoFile = new MockMultipartFile("file2", "filename2.mp4", "video/mp4", new byte[10]); - - mockMvc.perform(multipart("/api/v1/spots") - .file(imageFile) - .file(videoFile) - .param("pointJson","pointJson") - .param("title","title") - .param("description", "description") - .sessionAttr("memberId", accessor.getMemberId())) - .andExpect(status().isOk()); - } - - - - @DisplayName("spot 게시글이 조회될 때 200 응답과 response가 잘 반환되는지") - @Test - void whenGetSpotPost_thenReturns200AndSpotFindResponse() throws Exception { - SpotFindResponse mockResponse = new SpotFindResponse(); - given(spotService.findSpotByPostId(anyLong())).willReturn(mockResponse); - mockMvc.perform(get("/api/v1/spots/1") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @DisplayName("spot 게시글이 업데이트 요청이 왔을 때 200 응답과 response가 잘 반환되는지") - @Test - void whenUpdateSpotPost_thenReturns200AndSpotUpdateResponse() throws Exception { - SpotUpdateResponse mockResponse = new SpotUpdateResponse(); - SpotUpdateRequest mockRequest = new SpotUpdateRequest(); - Accessor accessor = Accessor.member(1L); - Long postId = 1L; - given(spotService.updateSpot(accessor.getMemberId(), postId, mockRequest)).willReturn(mockResponse); - MockMultipartFile imageFile = new MockMultipartFile("file", "filename.jpg", "image/jpeg", new byte[10]); - MockMultipartFile videoFile = new MockMultipartFile("file2", "filename2.mp4", "video/mp4", new byte[10]); - mockMvc.perform(multipart("/api/v1/spots/"+ postId) - .file(imageFile) - .file(videoFile) - .param("pointJson","pointJson").param("title","title") - .param("description", "description") - .sessionAttr("memberId", accessor.getMemberId())) - .andExpect(status().isOk()); - } -} \ No newline at end of file diff --git a/backend/yigil-api/src/test/java/kr/co/yigil/travel/util/GeoJsonConverterTest.java b/backend/yigil-api/src/test/java/kr/co/yigil/travel/util/GeoJsonConverterTest.java new file mode 100644 index 000000000..f4fc0a787 --- /dev/null +++ b/backend/yigil-api/src/test/java/kr/co/yigil/travel/util/GeoJsonConverterTest.java @@ -0,0 +1,64 @@ +package kr.co.yigil.travel.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Point; + +public class GeoJsonConverterTest { + + @DisplayName("유효한 GeoJson을 Point로 잘 변환하는지") + @Test + void convertToPoint_ValidGeoJson_ReturnsPoint() { + String validGeoJsonPoint = "{\"type\":\"Point\",\"coordinates\":[100.0,0.0]}"; + + Point point = GeojsonConverter.convertToPoint(validGeoJsonPoint); + + assertNotNull(point); + assertEquals(100.0, point.getX()); + assertEquals(0.0, point.getY()); + } + + @DisplayName("유효한 GeoJson을 LineString으로 잘 변환하는지") + @Test + void convertToLineString_ValidGeoJson_ReturnsLineString() { + String validGeoJsonLineString = "{\"type\":\"LineString\",\"coordinates\":[[100.0,0.0],[101.0,1.0]]}"; + + LineString lineString = GeojsonConverter.convertToLineString(validGeoJsonLineString); + + assertNotNull(lineString); + assertEquals(2, lineString.getNumPoints()); + assertEquals(100.0, lineString.getCoordinateN(0).x); + assertEquals(0.0, lineString.getCoordinateN(0).y); + assertEquals(101.0, lineString.getCoordinateN(1).x); + assertEquals(1.0, lineString.getCoordinateN(1).y); + } + + @DisplayName("Point를 유효한 GeoJson으로 변환하는지") + @Test + void convertToJson_Point_ReturnsValidGeoJson() { + Point point = GeojsonConverter.convertToPoint("{\"type\":\"Point\",\"coordinates\":[100.0,0.0]}"); + + String geoJson = GeojsonConverter.convertToJson(point); + + assertNotNull(geoJson); + assertTrue(geoJson.contains("\"type\":\"Point\"")); + assertTrue(geoJson.contains("\"coordinates\":[100,0.0]")); + } + + @DisplayName("LineString을 유효한 GeoJson으로 변환하는지") + @Test + void convertToJson_LineString_ReturnsValidGeoJson() { + LineString lineString = GeojsonConverter.convertToLineString("{\"type\":\"LineString\",\"coordinates\":[[100.0,0.0],[101.0,1.0]]}"); + + String geoJson = GeojsonConverter.convertToJson(lineString); + + assertNotNull(geoJson); + assertTrue(geoJson.contains("\"type\":\"LineString\"")); + assertTrue(geoJson.contains("\"coordinates\":[[100,0.0],[101,1]]")); + } +} diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index bffb357a7..97a2bb84e 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,3 +1,3 @@ { - "extends": "next/core-web-vitals" + "extends": ["next", "next/core-web-vitals"] } diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 000000000..e5c59ba0e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,67 @@ +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED 1 + +RUN npm run build + +# If using npm comment out above and use below instead +# RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +# set hostname to localhost +ENV HOSTNAME "0.0.0.0" + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD ["node", "server.js"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 5c2f1bfd6..6725d8da3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,75 +1,75 @@ -# 프론트엔드 중간점검 - -## 프로젝트 정보 - -- [Notion](https://www.notion.so/c8649ee9c28b4e06b76df4fb75afebb4?pvs=4) -- [Figma](https://www.figma.com/file/Ka7DHbg9d0n535k6AiDbzs/%EC%9D%B4%EA%B8%B8-%EC%96%B4%EB%95%8C?type=design&node-id=0%3A1&mode=design&t=JDe415NH7dswuMPV-1) - -## 기술 스택 & 팀원 역할 - -### 기술 스택 - -| 분류 | 기술 이름 | -| :---------: | :----------------------------------------------------------------------: | -| 언어 | [TypeScript](https://www.typescriptlang.org/) | -| 프레임워크 | [Next.js 14(App Router)](https://nextjs.org/) | -| 인증 | [NextAuth.js](https://next-auth.js.org/) | -| 스타일링 | [Tailwind CSS](https://tailwindcss.com/) | -| 지도 | [Naver Maps API](https://www.ncloud.com/product/applicationService/maps) | -| 스키마 검증 | [Zod](https://zod.dev/) | - -### 팀원 역할 - -**이경택** - -1. 로그인 기능 중 카카오 로그인 -2. 메인 페이지 헤더 -3. 메인 페이지 캐러셀 슬라이더 -4. `MSW(MockServiceWorker)` 환경설정 및 세팅 -5. 정욱님이 `Jest` 도입 해주셨으나 `MSW`와의 호환 문제로 `Vitest`로 변경 - -**박정욱** - -1. ‘로그인’ 화면의 구글 로그인 버튼 구현 -2. 하단 내비게이션 바 구현 -3. ‘홈’ 화면의 지역 목록 컴포넌트 구현 -4. 테스트 프레임워크 도입(Jest + React Testing Library) - -## 트러블슈팅 내용 - -1. Next.js의 `next/image`를 통해 SVG 파일을 불러오니 `fill`, `stroke` 등의 속성을 사용할 수 없었던 문제가 있어 SVGR 라이브러리를 도입하여 해결함 [\*](https://github.com/Kernel360/f1-Yigil/pull/111) -2. 테스트 라이브러리 `Jest`와 `MSW` 의 호환성 문제로 `Vitest` 로 변경 [\*](https://github.com/Kernel360/f1-Yigil/issues/130) - -## 성능 최적화를 위해 시도한 점 - -별도 없음 - -## 진행 상황 - -**디자인 변경으로 인해, 이하의 요소들은 새로 구현되거나 변경되어야 합니다.** - -1. ‘로그인’ 화면의 ‘**카카오 로그인 버튼**’, ‘**구글 로그인 버튼**’ - - 현재 각 Provider에 접근하여 토큰을 얻어올 수 있으며, 이후 백엔드 통신을 통해 로그인 처리를 완료하는 작업이 필요합니다. -2. ‘홈’ 화면의 ‘**헤더**’, ‘**캐러셀 슬라이더**’, ‘**지역 목록**’, ‘**하단 내비게이션 바**’ 컴포넌트 - -## 질문 사항 - -1. 디자인이 나오지 않았을 때 프론트엔드 작업자 입장에서 할 수 있는 일은 무엇이 있을까요? -2. 지도 라이브러리를 사용할 때 지도 인스턴스를 생성 후 다른 페이지에서 공유하는 지, - 혹은 페이지 언마운트시에 `useEffect` 훅을 통해 지우고 페이지마다 다시 그리는지 궁금합니다. - 지금 생각으로는 지도 컴포넌트를 만들어서 가져와서 쓸 것 같은데 언마운트 될 때 컴포넌트 내부에서 `destroy` 메서드를 통해 지도를 지웠다가 다시 그려야 할 지 궁금합니다. -3. 내비게이션의 각 메뉴에 대한 정보를 별도의 배열로 관리하고 있습니다. 각 아이템은 특정 메뉴에 대한 레이블, URL과 SVG 컴포넌트를 가지고 있습니다. 그리고 내비게이션을 관리하는 최상위 컴포넌트 `NavigationBar`에서 해당 배열을 순회하며 `NavigationIcon` 컴포넌트를 그리고 있습니다. 이 때, `NavigationIcon` 컴포넌트는 해당하는 아이콘을 그리기 위한 SVG 컴포넌트를 `Icon`이라는 이름으로 받아 그대로 그려내고 있습니다. 이런 형태가 좋지 않은 구현인지 아닌지 모르겠습니다. 만약 권장되지 않는 방법이라면 좀 더 일반적인 방법은 어떤 것이 있을지 알고 싶습니다. - - 이렇게 의심하게 된 가장 큰 이유는 prop을 대문자로 받고 있는 형태가 마음에 들지 않았기 때문입니다. -4. 현재까지는 복잡한 로직이 없어 상태 관리 라이브러리를 도입하지 않은 상태입니다. 상태 관리 라이브러리를 도입하기 적절한 시기가 언제일지 가늠하기 어려워 조언을 구하고 싶습니다. 처음부터 포함시키고 가는 것이 맞는지, React가 기본적으로 제공하는 상태 관리 방법으로는 부족함을 느끼게 되는 그때서야 도입을 검토하는 것이 맞을지... 어떤 방향성이 올바를지 여쭙고 싶습니다. - -## 최종발표까지 방향성 및 목표 - -1. 사용자가 불편함을 느끼지 않는 선에서 디자이너의 의도를 최대한 반영한 결과물을 낼 수 있으면 좋겠습니다. -2. 프로젝트의 핵심 가치인 ‘여행 장소 및 경로에 대한 느낌 공유’를 잊어버리지 않도록 구성원 모두와 자주 소통하고 싶습니다. -3. 디자인이 크게 변경되어 기존 진척도를 따지는 것이 의미가 없어졌으므로, 처음부터 다시 시작한다는 마인드로 진행하겠습니다. - -## 목표 - -1. 기능 명세서에 있는 기능들 중 중요도가 높은 것들을 모두 구현하는 것입니다. -2. 사용자가 핵심 기능을 사용할 때 플로우가 어긋나지 않고 자연스럽게 이어지도록 하는 것입니다. -3. 시간이 허락한다면 사용자의 행동을 유도하고 서비스에 대한 첫인상을 긍정적으로 가져가는 애니메이션을 여러 가지 추가하고 싶습니다. +# 프론트엔드 중간점검 + +## 프로젝트 정보 + +- [Notion](https://www.notion.so/c8649ee9c28b4e06b76df4fb75afebb4?pvs=4) +- [Figma](https://www.figma.com/file/Ka7DHbg9d0n535k6AiDbzs/%EC%9D%B4%EA%B8%B8-%EC%96%B4%EB%95%8C?type=design&node-id=0%3A1&mode=design&t=JDe415NH7dswuMPV-1) + +## 기술 스택 & 팀원 역할 + +### 기술 스택 + +| 분류 | 기술 이름 | +| :---------: | :----------------------------------------------------------------------: | +| 언어 | [TypeScript](https://www.typescriptlang.org/) | +| 프레임워크 | [Next.js 14(App Router)](https://nextjs.org/) | +| 인증 | [NextAuth.js](https://next-auth.js.org/) | +| 스타일링 | [Tailwind CSS](https://tailwindcss.com/) | +| 지도 | [Naver Maps API](https://www.ncloud.com/product/applicationService/maps) | +| 스키마 검증 | [Zod](https://zod.dev/) | + +### 팀원 역할 + +**이경택** + +1. 로그인 기능 중 카카오 로그인 +2. 메인 페이지 헤더 +3. 메인 페이지 캐러셀 슬라이더 +4. `MSW(MockServiceWorker)` 환경설정 및 세팅 +5. 정욱님이 `Jest` 도입 해주셨으나 `MSW`와의 호환 문제로 `Vitest`로 변경 + +**박정욱** + +1. ‘로그인’ 화면의 구글 로그인 버튼 구현 +2. 하단 내비게이션 바 구현 +3. ‘홈’ 화면의 지역 목록 컴포넌트 구현 +4. 테스트 프레임워크 도입(Jest + React Testing Library) + +## 트러블슈팅 내용 + +1. Next.js의 `next/image`를 통해 SVG 파일을 불러오니 `fill`, `stroke` 등의 속성을 사용할 수 없었던 문제가 있어 SVGR 라이브러리를 도입하여 해결함 [\*](https://github.com/Kernel360/f1-Yigil/pull/111) +2. 테스트 라이브러리 `Jest`와 `MSW` 의 호환성 문제로 `Vitest` 로 변경 [\*](https://github.com/Kernel360/f1-Yigil/issues/130) + +## 성능 최적화를 위해 시도한 점 + +별도 없음 + +## 진행 상황 + +**디자인 변경으로 인해, 이하의 요소들은 새로 구현되거나 변경되어야 합니다.** + +1. ‘로그인’ 화면의 ‘**카카오 로그인 버튼**’, ‘**구글 로그인 버튼**’ + - 현재 각 Provider에 접근하여 토큰을 얻어올 수 있으며, 이후 백엔드 통신을 통해 로그인 처리를 완료하는 작업이 필요합니다. +2. ‘홈’ 화면의 ‘**헤더**’, ‘**캐러셀 슬라이더**’, ‘**지역 목록**’, ‘**하단 내비게이션 바**’ 컴포넌트 + +## 질문 사항 + +1. 디자인이 나오지 않았을 때 프론트엔드 작업자 입장에서 할 수 있는 일은 무엇이 있을까요? +2. 지도 라이브러리를 사용할 때 지도 인스턴스를 생성 후 다른 페이지에서 공유하는 지, + 혹은 페이지 언마운트시에 `useEffect` 훅을 통해 지우고 페이지마다 다시 그리는지 궁금합니다. + 지금 생각으로는 지도 컴포넌트를 만들어서 가져와서 쓸 것 같은데 언마운트 될 때 컴포넌트 내부에서 `destroy` 메서드를 통해 지도를 지웠다가 다시 그려야 할 지 궁금합니다. +3. 내비게이션의 각 메뉴에 대한 정보를 별도의 배열로 관리하고 있습니다. 각 아이템은 특정 메뉴에 대한 레이블, URL과 SVG 컴포넌트를 가지고 있습니다. 그리고 내비게이션을 관리하는 최상위 컴포넌트 `NavigationBar`에서 해당 배열을 순회하며 `NavigationIcon` 컴포넌트를 그리고 있습니다. 이 때, `NavigationIcon` 컴포넌트는 해당하는 아이콘을 그리기 위한 SVG 컴포넌트를 `Icon`이라는 이름으로 받아 그대로 그려내고 있습니다. 이런 형태가 좋지 않은 구현인지 아닌지 모르겠습니다. 만약 권장되지 않는 방법이라면 좀 더 일반적인 방법은 어떤 것이 있을지 알고 싶습니다. + - 이렇게 의심하게 된 가장 큰 이유는 prop을 대문자로 받고 있는 형태가 마음에 들지 않았기 때문입니다. +4. 현재까지는 복잡한 로직이 없어 상태 관리 라이브러리를 도입하지 않은 상태입니다. 상태 관리 라이브러리를 도입하기 적절한 시기가 언제일지 가늠하기 어려워 조언을 구하고 싶습니다. 처음부터 포함시키고 가는 것이 맞는지, React가 기본적으로 제공하는 상태 관리 방법으로는 부족함을 느끼게 되는 그때서야 도입을 검토하는 것이 맞을지... 어떤 방향성이 올바를지 여쭙고 싶습니다. + +## 최종발표까지 방향성 및 목표 + +1. 사용자가 불편함을 느끼지 않는 선에서 디자이너의 의도를 최대한 반영한 결과물을 낼 수 있으면 좋겠습니다. +2. 프로젝트의 핵심 가치인 ‘여행 장소 및 경로에 대한 느낌 공유’를 잊어버리지 않도록 구성원 모두와 자주 소통하고 싶습니다. +3. 디자인이 크게 변경되어 기존 진척도를 따지는 것이 의미가 없어졌으므로, 처음부터 다시 시작한다는 마인드로 진행하겠습니다. + +## 목표 + +1. 기능 명세서에 있는 기능들 중 중요도가 높은 것들을 모두 구현하는 것입니다. +2. 사용자가 핵심 기능을 사용할 때 플로우가 어긋나지 않고 자연스럽게 이어지도록 하는 것입니다. +3. 시간이 허락한다면 사용자의 행동을 유도하고 서비스에 대한 첫인상을 긍정적으로 가져가는 애니메이션을 여러 가지 추가하고 싶습니다. diff --git a/frontend/__tests__/Carousel.test.tsx b/frontend/__tests__/Carousel.test.tsx deleted file mode 100644 index 88344a81b..000000000 --- a/frontend/__tests__/Carousel.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; -import Carousel from '@/app/_components/carousel/Carousel'; - -test('이미지를 불러와서 렌더링하는지 확인', async () => { - render(); - - const imgs = await screen.findAllByAltText('event-image'); - expect(imgs).toHaveLength(3); -}); diff --git a/frontend/__tests__/NavigationIcon.test.tsx b/frontend/__tests__/NavigationIcon.test.tsx deleted file mode 100644 index 3f85a5155..000000000 --- a/frontend/__tests__/NavigationIcon.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; - -import NavigationIcon from '@/app/_components/navigation/NavigationIcon'; -import { navigationData } from '@/app/_components/navigation/constants'; - -describe('NavigationIcon', () => { - const testCases = navigationData.map((data) => [data]); - - it.each(testCases)('renders Link component', (data) => { - const { href, label, icon } = data; - - render(); - - const link = screen.getByRole('link'); - - expect(link).toBeInTheDocument(); - }); -}); diff --git a/frontend/__tests__/PopOverItem.test.tsx b/frontend/__tests__/PopOverItem.test.tsx new file mode 100644 index 000000000..0e1092eec --- /dev/null +++ b/frontend/__tests__/PopOverItem.test.tsx @@ -0,0 +1,27 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import PopOverIcon from '@/app/_components/ui/popover/PopOverItem'; +import { headerPopOverData } from '@/app/_components/ui/popover/constants'; + +vi.mock('next/navigation', () => { + return { + useRouter: () => { + return { back: vi.fn() }; + }, + }; +}); + +describe('PopOver', () => { + const closeModal = () => { + false; + }; + + it.each(headerPopOverData)('render popover label', (data) => { + render(); + + const text = screen.queryByText(data.label); + + expect(text).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/SearchBar.test.tsx b/frontend/__tests__/SearchBar.test.tsx new file mode 100644 index 000000000..913d73875 --- /dev/null +++ b/frontend/__tests__/SearchBar.test.tsx @@ -0,0 +1,30 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import SearchBar from '@/app/_components/search/SearchBar'; +import { describe, expect, it } from 'vitest'; + +vi.mock('next/navigation', () => { + return { + useSearchParams: () => { + return { get: vi.fn() }; + }, + useRouter: () => { + return { back: vi.fn(), replace: vi.fn() }; + }, + usePathname: vi.fn(), + }; +}); + +describe('SearchBar', () => { + it('renders cancellable SearchBar', () => { + //mocking prop + const addResult = (result: string) => {}; + + render(); + + const button = screen.getByText('취소'); + + expect(button).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/Select.test.tsx b/frontend/__tests__/Select.test.tsx new file mode 100644 index 000000000..a64f217c2 --- /dev/null +++ b/frontend/__tests__/Select.test.tsx @@ -0,0 +1,51 @@ +import { sortOptions } from '@/app/_components/mypage/MyPageSelectBtns'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Select from '@/app/_components/ui/select/Select'; +import SelectOption from '@/app/_components/ui/select/SelectOption'; + +describe('Select Component', () => { + const closeModal = vi.fn(); + const onChangeSortOption = vi.fn(); + + it('render selectOption when isSortOpened is true', async () => { + render( + + + , + ); + + const rateButton = await screen.findByRole('button', { name: '별점순' }); + + fireEvent.click(rateButton); + + expect(onChangeSortOption).toHaveBeenCalledWith('rate'); + }); +}); diff --git a/frontend/__tests__/stateTransition.test.ts b/frontend/__tests__/stateTransition.test.ts new file mode 100644 index 000000000..8a2b0f644 --- /dev/null +++ b/frontend/__tests__/stateTransition.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'vitest'; + +import { makeInitialStep, reducer } from '@/app/_components/add/common/step'; + +import { + SPOT_STEP_STEP_COUNT, + COURSE_FROM_NEW_STEP_COUNT, + COURSE_FROM_EXISTING_STEP_COUNT, + initialSpotStep, + initialCourseFromNewStep, + initialCourseFromExistingStep, + endSpotStep, + endCourseFromNewStep, + endCourseFromExistingStep, +} from '@/app/_components/add/common/step/constants'; + +vi.mock('next/headers', async () => { + return { + cookies: () => { + return { + get: () => { + return { + value: 'cookie', + }; + }, + }; + }, + }; +}); + +describe('test', () => { + const initialStates = [ + { makingSpot: true, fromExisting: false, label: '장소' }, + { makingSpot: false, fromExisting: false, label: '일정만 기록하기' }, + { makingSpot: false, fromExisting: true, label: '장소도 함께 기록하기' }, + ]; + + test.each(initialStates)('$label', ({ makingSpot, fromExisting }) => { + const initialStep = makeInitialStep(makingSpot, fromExisting); + }); +}); + +describe('초기 상태 생성', () => { + const initialStates = [ + { makingSpot: true, fromExisting: false, label: '장소' }, + { makingSpot: false, fromExisting: false, label: '일정만 기록하기' }, + { makingSpot: false, fromExisting: true, label: '장소도 함께 기록하기' }, + ]; + + test.each(initialStates)('$label', ({ makingSpot, fromExisting }) => { + const initialStep = makeInitialStep(makingSpot, fromExisting); + + if (makingSpot) { + expect(initialStep).toEqual(initialSpotStep); + } else { + if (fromExisting) { + expect(initialStep).toEqual(initialCourseFromExistingStep); + } else { + expect(initialStep).toEqual(initialCourseFromNewStep); + } + } + }); +}); + +describe('다음 상태 전이 테스트', () => { + test('장소 추가 단계 처음부터 끝까지', () => { + let step = makeInitialStep(true, false); + + // Exclude start + for (let i = 0; i < SPOT_STEP_STEP_COUNT - 1; i++) { + step = reducer(step, { type: 'next' }); + } + + expect(step).toEqual(endSpotStep); + }); + + test('일정만 기록하기 단계 처음부터 끝까지', () => { + let step = makeInitialStep(false, true); + + // Exclude start + for (let i = 0; i < COURSE_FROM_EXISTING_STEP_COUNT - 1; i++) { + step = reducer(step, { type: 'next' }); + } + + expect(step).toEqual(endCourseFromExistingStep); + }); + + test('장소도 함께 기록하기 처음부터 끝까지', () => { + let step = makeInitialStep(false, false); + + // Exclude start + for (let i = 0; i < COURSE_FROM_NEW_STEP_COUNT - 1; i++) { + step = reducer(step, { type: 'next' }); + } + + expect(step).toEqual(endCourseFromNewStep); + }); +}); + +describe('이전 상태 전이 테스트', () => { + test('장소 추가 단계 끝부터 처음까지', () => { + let step = endSpotStep; + + // Exclude start + for (let i = 0; i < SPOT_STEP_STEP_COUNT - 1; i++) { + step = reducer(step, { type: 'previous' }); + } + + expect(step).toEqual(initialSpotStep); + }); + + test('일정만 기록하기 단계 끝부터 처음까지', () => { + let step = endCourseFromExistingStep; + + // Exclude start + for (let i = 0; i < COURSE_FROM_EXISTING_STEP_COUNT - 1; i++) { + step = reducer(step, { type: 'previous' }); + } + + expect(step).toEqual(initialCourseFromExistingStep); + }); + + test('장소도 함께 기록하기 단계 끝부터 처음까지', () => { + let step = endCourseFromNewStep; + + // Exclude start + for (let i = 0; i < COURSE_FROM_NEW_STEP_COUNT - 1; i++) { + step = reducer(step, { type: 'previous' }); + } + + expect(step).toEqual(initialCourseFromNewStep); + }); +}); diff --git a/frontend/app.json b/frontend/app.json new file mode 100644 index 000000000..5f394f2da --- /dev/null +++ b/frontend/app.json @@ -0,0 +1,10 @@ +{ + "name": "nextjs", + "options": { + "allow-unauthenticated": true, + "memory": "256Mi", + "cpu": "1", + "port": 3000, + "http2": false + } +} diff --git a/frontend/next.config.js b/frontend/next.config.js index 260e30794..fbfa27d9f 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -40,8 +40,38 @@ const nextConfig = { port: '', pathname: '/**', }, + { + protocol: 'https', + hostname: 'lh3.googleusercontent.com', + port: '', + pathname: '/**', + }, + { + protocol: 'http', + hostname: 'k.kakaocdn.net', + port: '', + pathname: '/**', + }, + { + protocol: 'https', + hostname: 'placehold.co', + port: '', + pathname: '/**', + }, + { + protocol: 'http', + hostname: 'cdn.yigil.co.kr', + port: '', + pathname: '/**', + }, ], }, + output: 'standalone', + experimental: { + serverActions: { + bodySizeLimit: '250mb', + }, + }, }; module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2de63cd79..dccab7db9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,14 +8,18 @@ "name": "frontend", "version": "0.1.0", "dependencies": { - "@auth/core": "^0.18.5", - "axios": "^1.6.2", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-toast": "^1.1.5", "embla-carousel-react": "^8.0.0-rc18", "next": "14.0.4", - "next-auth": "^4.24.5", "react": "^18", "react-dom": "^18", - "vite-plugin-svgr": "^4.2.0" + "react-naver-maps": "^0.1.3", + "sharp": "^0.33.2", + "vite-plugin-svgr": "^4.2.0", + "zod": "^3.22.4" }, "devDependencies": { "@mswjs/http-middleware": "^0.9.2", @@ -25,6 +29,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.11", + "@types/navermaps": "^3.7.4", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -53,9 +58,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", - "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", + "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", "dev": true }, "node_modules/@alloc/quick-lru": { @@ -82,26 +87,15 @@ "node": ">=6.0.0" } }, - "node_modules/@auth/core": { - "version": "0.18.6", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.18.6.tgz", - "integrity": "sha512-AI7tnyNOg5zVS2elA44O03MWS6jyp69S8habJts63IyiCIoSkhqcZX4EcEJ0287m0hxzOltPCV9Zq2aqXbrNSA==", + "node_modules/@asamuzakjp/dom-selector": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", + "integrity": "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==", + "dev": true, "dependencies": { - "@panva/hkdf": "^1.1.1", - "@types/cookie": "0.6.0", - "cookie": "0.6.0", - "jose": "^5.1.3", - "oauth4webapi": "^2.4.0", - "preact": "10.11.3", - "preact-render-to-string": "5.2.3" - }, - "peerDependencies": { - "nodemailer": "^6.8.0" - }, - "peerDependenciesMeta": { - "nodemailer": { - "optional": true - } + "bidi-js": "^1.0.3", + "css-tree": "^2.3.1", + "is-potential-custom-element-name": "^1.0.1" } }, "node_modules/@babel/code-frame": { @@ -116,70 +110,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/code-frame/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/compat-data": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", @@ -189,20 +119,20 @@ } }, "node_modules/@babel/core": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz", - "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", + "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", "@babel/generator": "^7.23.6", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.6", - "@babel/parser": "^7.23.6", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.6", - "@babel/types": "^7.23.6", + "@babel/helpers": "^7.23.9", + "@babel/parser": "^7.23.9", + "@babel/template": "^7.23.9", + "@babel/traverse": "^7.23.9", + "@babel/types": "^7.23.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -217,25 +147,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", @@ -289,31 +200,10 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" - }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.6.tgz", - "integrity": "sha512-cBXU1vZni/CpGF29iTu4YRbOZt3Wat6zCoMDxRF1MayiEc4URxOj31tT65HUM0CRpMowA3HCJaAOVOUnMf96cw==", + "version": "7.23.10", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.10.tgz", + "integrity": "sha512-2XpP2XhkXzgxecPNEEK8Vz8Asj9aRxt08oKOqtiZoqV2UGZ5T+EkyP9sXQ9nwMxBIG34a7jmasVqoMop7VdPUw==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", @@ -333,15 +223,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", @@ -359,19 +240,10 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz", - "integrity": "sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", + "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -584,13 +456,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.6.tgz", - "integrity": "sha512-wCfsbN4nBidDRhpDhvcKlzHWCTlgJYUUdSJfzXb2NuBssDSIjc3xcb+znA7l+zYsFljAcGM0aFkN40cR3lXiGA==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.9.tgz", + "integrity": "sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==", "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.6", - "@babel/types": "^7.23.6" + "@babel/template": "^7.23.9", + "@babel/traverse": "^7.23.9", + "@babel/types": "^7.23.9" }, "engines": { "node": ">=6.9.0" @@ -609,74 +481,10 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", "bin": { "parser": "bin/babel-parser.js" }, @@ -717,9 +525,9 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.3.tgz", - "integrity": "sha512-XaJak1qcityzrX0/IU5nKHb34VaibwP3saKqG6a/tppelgllOH13LUann4ZCIBcVOeE6H18K4Vx9QKkVww3z/w==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.7.tgz", + "integrity": "sha512-LlRT7HgaifEpQA1ZgLVOIJZZFVPWN5iReq/7/JixwBtwcoeVGDBD53ZV28rrsLYOZs1Y/EHhA8N/Z6aazHR8cw==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", @@ -756,20 +564,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-class-properties": { "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", @@ -1039,9 +833,9 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.4.tgz", - "integrity": "sha512-efdkfPhHYTtn0G6n2ddrESE91fgXxjlqLsnUtPWnJs4a4mZIbUaK7ffqKIIUKXSHwcDvaCVX6GXkaJJFqtX7jw==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.9.tgz", + "integrity": "sha512-8Q3veQEDGe14dTYuwagbRtwxQDnytyg1JFu4/HwEMETeofocrB0U0ejBJIXoeG/t2oXZ8kzCyI0ZZfbT80VFNQ==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", @@ -1137,16 +931,15 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.5.tgz", - "integrity": "sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg==", + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.8.tgz", + "integrity": "sha512-yAYslGsY1bX6Knmg46RjiCiNSwJKv2IUC8qOdYKqMMr0491SXFhcHqOdRDeCRohOOIzwN/90C6mQ9qAKgrP7dg==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5", "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-replace-supers": "^7.22.20", "@babel/helper-split-export-declaration": "^7.22.6", @@ -1159,15 +952,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-classes/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.23.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz", @@ -1407,9 +1191,9 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz", - "integrity": "sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.9.tgz", + "integrity": "sha512-KDlPRM6sLo4o1FkiSlXoAa8edLXFsKKIda779fbLrvmeuc3itnjCtaO6RrtoaANsIJANj+Vk1zqbZIMhkCAHVw==", "dev": true, "dependencies": { "@babel/helper-hoist-variables": "^7.22.5", @@ -1934,9 +1718,9 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.6.tgz", - "integrity": "sha512-2XPn/BqKkZCpzYhUUNZ1ssXw7DcXfKQEjv/uXZUXgaebCMYmkEsfZ2yY+vv+xtXv50WmL5SGhyB6/xsWxIvvOQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.9.tgz", + "integrity": "sha512-3kBGTNBBk9DQiPoXYS0g0BYlwTQYUTifqgKTjxUwEUkduRT2QOa0FPGBJ+NROQhGyYO5BuTJwGvBnqKDykac6A==", "dev": true, "dependencies": { "@babel/compat-data": "^7.23.5", @@ -1945,7 +1729,7 @@ "@babel/helper-validator-option": "^7.23.5", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.3", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", @@ -1966,13 +1750,13 @@ "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.23.3", - "@babel/plugin-transform-async-generator-functions": "^7.23.4", + "@babel/plugin-transform-async-generator-functions": "^7.23.9", "@babel/plugin-transform-async-to-generator": "^7.23.3", "@babel/plugin-transform-block-scoped-functions": "^7.23.3", "@babel/plugin-transform-block-scoping": "^7.23.4", "@babel/plugin-transform-class-properties": "^7.23.3", "@babel/plugin-transform-class-static-block": "^7.23.4", - "@babel/plugin-transform-classes": "^7.23.5", + "@babel/plugin-transform-classes": "^7.23.8", "@babel/plugin-transform-computed-properties": "^7.23.3", "@babel/plugin-transform-destructuring": "^7.23.3", "@babel/plugin-transform-dotall-regex": "^7.23.3", @@ -1988,7 +1772,7 @@ "@babel/plugin-transform-member-expression-literals": "^7.23.3", "@babel/plugin-transform-modules-amd": "^7.23.3", "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-modules-systemjs": "^7.23.3", + "@babel/plugin-transform-modules-systemjs": "^7.23.9", "@babel/plugin-transform-modules-umd": "^7.23.3", "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", "@babel/plugin-transform-new-target": "^7.23.3", @@ -2014,9 +1798,9 @@ "@babel/plugin-transform-unicode-regex": "^7.23.3", "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.6", - "babel-plugin-polyfill-corejs3": "^0.8.5", - "babel-plugin-polyfill-regenerator": "^0.5.3", + "babel-plugin-polyfill-corejs2": "^0.4.8", + "babel-plugin-polyfill-corejs3": "^0.9.0", + "babel-plugin-polyfill-regenerator": "^0.5.5", "core-js-compat": "^3.31.0", "semver": "^6.3.1" }, @@ -2027,15 +1811,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -2096,9 +1871,9 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz", - "integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", + "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -2107,22 +1882,22 @@ } }, "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", + "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz", - "integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", + "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", "dependencies": { "@babel/code-frame": "^7.23.5", "@babel/generator": "^7.23.6", @@ -2130,8 +1905,8 @@ "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2139,18 +1914,10 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", + "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -2160,14 +1927,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.0.tgz", @@ -2186,15 +1945,6 @@ "node": ">= 0.6" } }, - "node_modules/@bundled-es-modules/js-levenshtein": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/js-levenshtein/-/js-levenshtein-2.0.1.tgz", - "integrity": "sha512-DERMS3yfbAljKsQc0U2wcqGKUWpdFjwqWuoMugEJlqBnKO180/n+4SR/J8MRDt1AN48X1ovgoD9KrdVXcaa3Rg==", - "dev": true, - "dependencies": { - "js-levenshtein": "^1.1.6" - } - }, "node_modules/@bundled-es-modules/statuses": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", @@ -2226,28 +1976,86 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", - "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", - "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", - "cpu": [ - "arm" - ], + "node_modules/@dnd-kit/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", + "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], "optional": true, "os": [ "android" @@ -2257,9 +2065,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", - "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", "cpu": [ "arm64" ], @@ -2272,9 +2080,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", - "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", "cpu": [ "x64" ], @@ -2287,9 +2095,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", - "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", "cpu": [ "arm64" ], @@ -2302,9 +2110,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", - "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", "cpu": [ "x64" ], @@ -2317,9 +2125,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", - "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", "cpu": [ "arm64" ], @@ -2332,9 +2140,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", - "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", "cpu": [ "x64" ], @@ -2347,9 +2155,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", - "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", "cpu": [ "arm" ], @@ -2362,9 +2170,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", - "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", "cpu": [ "arm64" ], @@ -2377,9 +2185,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", - "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", "cpu": [ "ia32" ], @@ -2392,9 +2200,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", - "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", "cpu": [ "loong64" ], @@ -2407,9 +2215,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", - "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", "cpu": [ "mips64el" ], @@ -2422,9 +2230,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", - "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", "cpu": [ "ppc64" ], @@ -2437,9 +2245,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", - "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", "cpu": [ "riscv64" ], @@ -2452,9 +2260,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", - "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", "cpu": [ "s390x" ], @@ -2467,9 +2275,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", - "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", "cpu": [ "x64" ], @@ -2482,9 +2290,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", - "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", "cpu": [ "x64" ], @@ -2497,9 +2305,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", - "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", "cpu": [ "x64" ], @@ -2512,9 +2320,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", - "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", "cpu": [ "x64" ], @@ -2527,9 +2335,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", - "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", "cpu": [ "arm64" ], @@ -2542,9 +2350,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", - "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", "cpu": [ "ia32" ], @@ -2557,9 +2365,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", - "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", "cpu": [ "x64" ], @@ -2618,23 +2426,50 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.55.0.tgz", - "integrity": "sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { @@ -2655,464 +2490,668 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.2.tgz", + "integrity": "sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==", + "cpu": [ + "arm64" + ], "optional": true, - "peer": true, - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "sprintf-js": "~1.0.2" + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.1" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz", + "integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==", + "cpu": [ + "x64" + ], "optional": true, - "peer": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6" + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.1" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==", + "cpu": [ + "arm64" + ], "optional": true, - "peer": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz", + "integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==", + "cpu": [ + "x64" + ], "optional": true, - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.1.tgz", + "integrity": "sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==", + "cpu": [ + "arm" + ], "optional": true, - "peer": true, - "dependencies": { - "p-locate": "^4.1.0" - }, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.1.tgz", + "integrity": "sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==", + "cpu": [ + "arm64" + ], "optional": true, - "peer": true, - "dependencies": { - "p-try": "^2.0.0" - }, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.1.tgz", + "integrity": "sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==", + "cpu": [ + "s390x" + ], "optional": true, - "peer": true, - "dependencies": { - "p-limit": "^2.2.0" - }, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.1.tgz", + "integrity": "sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==", + "cpu": [ + "x64" + ], "optional": true, - "peer": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.1.tgz", + "integrity": "sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==", + "cpu": [ + "arm64" + ], "optional": true, - "peer": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.1.tgz", + "integrity": "sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==", + "cpu": [ + "x64" + ], "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, + "os": [ + "linux" + ], "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.2.tgz", + "integrity": "sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==", + "cpu": [ + "arm" + ], "optional": true, - "peer": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, + "os": [ + "linux" + ], "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "funding": { + "url": "https://opencollective.com/libvips" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.1" } }, - "node_modules/@jest/core/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.2.tgz", + "integrity": "sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==", + "cpu": [ + "arm64" + ], "optional": true, - "peer": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.1" } }, - "node_modules/@jest/core/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.2.tgz", + "integrity": "sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.2.tgz", + "integrity": "sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.2.tgz", + "integrity": "sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.2.tgz", + "integrity": "sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.2.tgz", + "integrity": "sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==", + "cpu": [ + "wasm32" + ], "optional": true, - "peer": true, "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@emnapi/runtime": "^0.45.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jest/core/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.2.tgz", + "integrity": "sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==", + "cpu": [ + "ia32" + ], "optional": true, - "peer": true + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.2.tgz", + "integrity": "sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==", + "cpu": [ + "x64" + ], "optional": true, - "peer": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@inquirer/confirm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.0.0.tgz", + "integrity": "sha512-LHeuYP1D8NmQra1eR4UqvZMXwxEdDXyElJmmZfU44xdNLL6+GcQBS0uE16vyfZVjH8c22p9e+DStROfE/hyHrg==", + "dev": true, "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" + "@inquirer/core": "^7.0.0", + "@inquirer/type": "^1.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" } }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "node_modules/@inquirer/core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-7.0.0.tgz", + "integrity": "sha512-g13W5yEt9r1sEVVriffJqQ8GWy94OnfxLCreNSOTw0HPVcszmc/If1KIf7YBmlwtX4klmvwpZHnQpl3N7VX2xA==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" + "@inquirer/type": "^1.2.0", + "@types/mute-stream": "^0.0.4", + "@types/node": "^20.11.16", + "@types/wrap-ansi": "^3.0.0", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "cli-spinners": "^2.9.2", + "cli-width": "^4.1.0", + "figures": "^3.2.0", + "mute-stream": "^1.0.0", + "run-async": "^3.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "node_modules/@inquirer/core/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "dependencies": { - "jest-get-type": "^29.6.3" + "color-convert": "^2.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@inquirer/core/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "node_modules/@inquirer/core/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "color-name": "~1.1.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=7.0.0" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "node_modules/@inquirer/core/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@inquirer/core/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" + "has-flag": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" + } + }, + "node_modules/@inquirer/type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.2.0.tgz", + "integrity": "sha512-/vvkUkYhrjbm+RolU7V1aUFDydZVKNKqKHR5TsE+j5DXgXFwrsOPcoGUJ02K0O7q7O53CU2DOTMYCHeGZ25WHA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "dependencies": { - "@sinclair/typebox": "^0.27.8" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "ansi-regex": "^6.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@jest/test-sequencer": { + "node_modules/@jest/expect-utils": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" + "jest-get-type": "^29.6.3" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "@sinclair/typebox": "^0.27.8" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -3135,6 +3174,76 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/types/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/types/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@jest/types/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/types/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -3149,9 +3258,9 @@ } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "engines": { "node": ">=6.0.0" } @@ -3170,9 +3279,9 @@ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", + "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3204,9 +3313,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.25.13", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.13.tgz", - "integrity": "sha512-xfjR81WwXPHwhDbqJRHlxYmboJuiSaIKpP4I5TJVFl/EmByOU13jOBT9hmEnxcjR3jvFYoqoNKt7MM9uqerj9A==", + "version": "0.25.16", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.16.tgz", + "integrity": "sha512-8QC8JyKztvoGAdPgyZy49c9vSHHAZjHagwl4RY9E8carULk8ym3iTaiawrT1YoLF/qb449h48f71XDPgkUSOUg==", "dev": true, "dependencies": { "@open-draft/deferred-promise": "^2.2.0", @@ -3400,47 +3509,351 @@ "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" }, - "engines": { - "node": ">= 8" + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.5.tgz", + "integrity": "sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true - }, - "node_modules/@panva/hkdf": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.1.1.tgz", - "integrity": "sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==", - "funding": { - "url": "https://github.com/sponsors/panva" + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.24", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", - "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", - "dev": true, - "optional": true, - "peer": true + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", + "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } }, "node_modules/@rollup/pluginutils": { "version": "5.1.0", @@ -3463,15 +3876,10 @@ } } }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" - }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.2.tgz", - "integrity": "sha512-RKzxFxBHq9ysZ83fn8Iduv3A283K7zPPYuhL/z9CQuyFrjwpErJx0h4aeb/bnJ+q29GRLgJpY66ceQ/Wcsn3wA==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz", + "integrity": "sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==", "cpu": [ "arm" ], @@ -3481,9 +3889,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.2.tgz", - "integrity": "sha512-yZ+MUbnwf3SHNWQKJyWh88ii2HbuHCFQnAYTeeO1Nb8SyEiWASEi5dQUygt3ClHWtA9My9RQAYkjvrsZ0WK8Xg==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz", + "integrity": "sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==", "cpu": [ "arm64" ], @@ -3493,9 +3901,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.2.tgz", - "integrity": "sha512-vqJ/pAUh95FLc/G/3+xPqlSBgilPnauVf2EXOQCZzhZJCXDXt/5A8mH/OzU6iWhb3CNk5hPJrh8pqJUPldN5zw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz", + "integrity": "sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==", "cpu": [ "arm64" ], @@ -3505,9 +3913,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.2.tgz", - "integrity": "sha512-otPHsN5LlvedOprd3SdfrRNhOahhVBwJpepVKUN58L0RnC29vOAej1vMEaVU6DadnpjivVsNTM5eNt0CcwTahw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz", + "integrity": "sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==", "cpu": [ "x64" ], @@ -3517,9 +3925,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.2.tgz", - "integrity": "sha512-ewG5yJSp+zYKBYQLbd1CUA7b1lSfIdo9zJShNTyc2ZP1rcPrqyZcNlsHgs7v1zhgfdS+kW0p5frc0aVqhZCiYQ==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz", + "integrity": "sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==", "cpu": [ "arm" ], @@ -3529,9 +3937,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.2.tgz", - "integrity": "sha512-pL6QtV26W52aCWTG1IuFV3FMPL1m4wbsRG+qijIvgFO/VBsiXJjDPE/uiMdHBAO6YcpV4KvpKtd0v3WFbaxBtg==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz", + "integrity": "sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==", "cpu": [ "arm64" ], @@ -3541,9 +3949,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.2.tgz", - "integrity": "sha512-On+cc5EpOaTwPSNetHXBuqylDW+765G/oqB9xGmWU3npEhCh8xu0xqHGUA+4xwZLqBbIZNcBlKSIYfkBm6ko7g==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz", + "integrity": "sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==", "cpu": [ "arm64" ], @@ -3553,9 +3961,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.2.tgz", - "integrity": "sha512-Wnx/IVMSZ31D/cO9HSsU46FjrPWHqtdF8+0eyZ1zIB5a6hXaZXghUKpRrC4D5DcRTZOjml2oBhXoqfGYyXKipw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz", + "integrity": "sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==", "cpu": [ "riscv64" ], @@ -3565,9 +3973,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.2.tgz", - "integrity": "sha512-ym5x1cj4mUAMBummxxRkI4pG5Vht1QMsJexwGP8547TZ0sox9fCLDHw9KCH9c1FO5d9GopvkaJsBIOkTKxksdw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz", + "integrity": "sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==", "cpu": [ "x64" ], @@ -3577,9 +3985,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.2.tgz", - "integrity": "sha512-m0hYELHGXdYx64D6IDDg/1vOJEaiV8f1G/iO+tejvRCJNSwK4jJ15e38JQy5Q6dGkn1M/9KcyEOwqmlZ2kqaZg==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz", + "integrity": "sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==", "cpu": [ "x64" ], @@ -3589,9 +3997,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.2.tgz", - "integrity": "sha512-x1CWburlbN5JjG+juenuNa4KdedBdXLjZMp56nHFSHTOsb/MI2DYiGzLtRGHNMyydPGffGId+VgjOMrcltOksA==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz", + "integrity": "sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==", "cpu": [ "arm64" ], @@ -3601,9 +4009,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.2.tgz", - "integrity": "sha512-VVzCB5yXR1QlfsH1Xw1zdzQ4Pxuzv+CPr5qpElpKhVxlxD3CRdfubAG9mJROl6/dmj5gVYDDWk8sC+j9BI9/kQ==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz", + "integrity": "sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==", "cpu": [ "ia32" ], @@ -3613,9 +4021,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.2.tgz", - "integrity": "sha512-SYRedJi+mweatroB+6TTnJYLts0L0bosg531xnQWtklOI6dezEagx4Q0qDyvRdK+qgdA3YZpjjGuPFtxBmddBA==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz", + "integrity": "sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==", "cpu": [ "x64" ], @@ -3625,9 +4033,9 @@ ] }, "node_modules/@rushstack/eslint-patch": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz", - "integrity": "sha512-2/U3GXA6YiPYQDLGwtGlnNgKYBSwCFIHf8Y9LUY5VATHdtbLlU0Y1R3QoBnT0aB4qv/BEiVVsj7LJXoQCgJ2vA==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.7.2.tgz", + "integrity": "sha512-RbhOOTCNoCrbfkRyoXODZp75MlpiHMgbE5MEBZAnnnLyQNgrigEj4p0lzsMDyc1zVsJDLrivB58tgg3emX0eEA==", "dev": true }, "node_modules/@sinclair/typebox": { @@ -3636,28 +4044,6 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, - "node_modules/@sinonjs/commons": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", - "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -3912,9 +4298,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", - "integrity": "sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==", + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", "dev": true, "dependencies": { "@babel/code-frame": "^7.10.4", @@ -3931,12 +4317,15 @@ } }, "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=10" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -3951,30 +4340,71 @@ "deep-equal": "^2.0.5" } }, - "node_modules/@testing-library/dom/node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@testing-library/dom/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "node_modules/@testing-library/dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true }, + "node_modules/@testing-library/dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@testing-library/jest-dom": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.2.0.tgz", - "integrity": "sha512-+BVQlJ9cmEn5RDMUS8c2+TU6giLvzaHZ8sU/x0Jj7fk+6/46wPdwlgOPcpxS17CjcanBi/3VmGMqVr2rmbUmNw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz", + "integrity": "sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==", "dev": true, "dependencies": { "@adobe/css-tools": "^4.3.2", @@ -3993,6 +4423,7 @@ }, "peerDependencies": { "@jest/globals": ">= 28", + "@types/bun": "latest", "@types/jest": ">= 28", "jest": ">= 28", "vitest": ">= 0.32" @@ -4001,6 +4432,9 @@ "@jest/globals": { "optional": true }, + "@types/bun": { + "optional": true + }, "@types/jest": { "optional": true }, @@ -4012,6 +4446,21 @@ } } }, + "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/@testing-library/jest-dom/node_modules/chalk": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", @@ -4025,16 +4474,49 @@ "node": ">=8" } }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/@testing-library/jest-dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@testing-library/react": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.1.2.tgz", - "integrity": "sha512-z4p7DVBTPjKM5qDZ0t5ZjzkpSNb+fZy1u6bzO7kk8oeGagpPCAtgh4cx1syrfp7a+QWkM021jGqjJaxJJnXAZg==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.2.1.tgz", + "integrity": "sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A==", "dev": true, "dependencies": { "@babel/runtime": "^7.12.5", @@ -4151,7 +4633,8 @@ "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true }, "node_modules/@types/cors": { "version": "2.8.17", @@ -4180,9 +4663,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.41", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", - "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", + "version": "4.17.43", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", + "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", "dev": true, "dependencies": { "@types/node": "*", @@ -4191,16 +4674,11 @@ "@types/send": "*" } }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*" - } + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "dev": true }, "node_modules/@types/http-errors": { "version": "2.0.4", @@ -4233,9 +4711,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.11", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz", - "integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==", + "version": "29.5.12", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", + "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -4274,11 +4752,10 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, - "node_modules/@types/js-levenshtein": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.3.tgz", - "integrity": "sha512-jd+Q+sD20Qfu9e2aEXogiO3vpOC1PYJOUdyN9gvs4Qrvkg4wF43L5OhqrPeokdv8TL0/mXoYfpkcoGZMNN2pkQ==", - "dev": true + "node_modules/@types/js-cookie": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", + "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==" }, "node_modules/@types/json5": { "version": "0.0.29", @@ -4292,10 +4769,28 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/mute-stream": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", + "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/navermaps": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@types/navermaps/-/navermaps-3.7.4.tgz", + "integrity": "sha512-fAazswZgl/l/1Mw375hJcNAC244SiTA0+Y6HE8atkVAtrdawlpgpkaRMCpFX+15iDiQzTfO0MmE+y1+6NpWFuQ==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { - "version": "20.10.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz", - "integrity": "sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==", + "version": "20.11.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz", + "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==", "devOptional": true, "dependencies": { "undici-types": "~5.26.4" @@ -4305,7 +4800,7 @@ "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "dev": true + "devOptional": true }, "node_modules/@types/qs": { "version": "6.9.11", @@ -4320,10 +4815,10 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.2.45", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.45.tgz", - "integrity": "sha512-TtAxCNrlrBp8GoeEp1npd5g+d/OejJHFxS3OWmrPBMFaVQMSN0OFySozJio5BHxTuTeug00AVXVAjfDSfk+lUg==", - "dev": true, + "version": "18.2.57", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.57.tgz", + "integrity": "sha512-ZvQsktJgSYrQiMirAN60y4O/LRevIV8hUzSOSNB6gfR3/o3wCBFQx3sPwIYtuDMeiVgsSS3UzCV26tEzgnfvQw==", + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4331,10 +4826,10 @@ } }, "node_modules/@types/react-dom": { - "version": "18.2.17", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.17.tgz", - "integrity": "sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==", - "dev": true, + "version": "18.2.19", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.19.tgz", + "integrity": "sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==", + "devOptional": true, "dependencies": { "@types/react": "*" } @@ -4343,7 +4838,7 @@ "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "dev": true + "devOptional": true }, "node_modules/@types/send": { "version": "0.17.4", @@ -4378,6 +4873,12 @@ "integrity": "sha512-eqNDvZsCNY49OAXB0Firg/Sc2BgoWsntsLUdybGFOhAfCD6QJ2n9HXUIHGqt5qjrxmMv4wS8WLAw43ZkKcJ8Pw==", "dev": true }, + "node_modules/@types/wrap-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", + "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", + "dev": true + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -4394,15 +4895,15 @@ "dev": true }, "node_modules/@typescript-eslint/parser": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.14.0.tgz", - "integrity": "sha512-QjToC14CKacd4Pa7JK4GeB/vHmWFJckec49FR4hmIRf97+KXole0T97xxu9IFiPxVQ1DBWrQ5wreLwAGwWAVQA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.14.0", - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/typescript-estree": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4" }, "engines": { @@ -4422,13 +4923,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.14.0.tgz", - "integrity": "sha512-VT7CFWHbZipPncAZtuALr9y3EuzY1b1t1AEkIq2bTXUPKw+pHoXflGNG5L+Gv6nKul1cz1VH8fz16IThIU0tdg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0" + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -4439,9 +4940,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.14.0.tgz", - "integrity": "sha512-uty9H2K4Xs8E47z3SnXEPRNDfsis8JO27amp2GNCnzGETEW3yTqEIVg5+AI7U276oGF/tw6ZA+UesxeQ104ceA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -4452,16 +4953,17 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.14.0.tgz", - "integrity": "sha512-yPkaLwK0yH2mZKFE/bXkPAkkFgOv15GJAUzgUVonAbv0Hr4PK/N2yaA/4XQbTZQdygiDkpt5DkxPELqHguNvyw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", + "minimatch": "9.0.3", "semver": "^7.5.4", "ts-api-utils": "^1.0.1" }, @@ -4472,19 +4974,76 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.14.0.tgz", - "integrity": "sha512-fB5cw6GRhJUz03MrROVuj5Zm/Q+XWlVdIsFj+Zb1Hvqouc8t+XP2H5y53QYU/MGtd2dPg6/vJJlhoX3xc2ehfw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.14.0", + "@typescript-eslint/types": "6.21.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -4521,73 +5080,26 @@ } }, "node_modules/@vitest/expect": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.1.3.tgz", - "integrity": "sha512-MnJqsKc1Ko04lksF9XoRJza0bGGwTtqfbyrsYv5on4rcEkdo+QgUdITenBQBUltKzdxW7K3rWh+nXRULwsdaVg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.0.tgz", + "integrity": "sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==", "dev": true, "dependencies": { - "@vitest/spy": "1.1.3", - "@vitest/utils": "1.1.3", + "@vitest/spy": "1.3.0", + "@vitest/utils": "1.3.0", "chai": "^4.3.10" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/expect/node_modules/@vitest/utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.1.3.tgz", - "integrity": "sha512-Dyt3UMcdElTll2H75vhxfpZu03uFpXRCHxWnzcrFjZxT1kTbq8ALUYIeBgGolo1gldVdI0YSlQRacsqxTwNqwg==", - "dev": true, - "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/expect/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@vitest/expect/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@vitest/expect/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, "node_modules/@vitest/runner": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.1.3.tgz", - "integrity": "sha512-Va2XbWMnhSdDEh/OFxyUltgQuuDRxnarK1hW5QNN4URpQrqq6jtt8cfww/pQQ4i0LjoYxh/3bYWvDFlR9tU73g==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.0.tgz", + "integrity": "sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==", "dev": true, "dependencies": { - "@vitest/utils": "1.1.3", + "@vitest/utils": "1.3.0", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -4595,33 +5107,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.1.3.tgz", - "integrity": "sha512-Dyt3UMcdElTll2H75vhxfpZu03uFpXRCHxWnzcrFjZxT1kTbq8ALUYIeBgGolo1gldVdI0YSlQRacsqxTwNqwg==", - "dev": true, - "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@vitest/runner/node_modules/p-limit": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", @@ -4637,26 +5122,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@vitest/runner/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@vitest/runner/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, "node_modules/@vitest/runner/node_modules/yocto-queue": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", @@ -4670,9 +5135,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.1.3.tgz", - "integrity": "sha512-U0r8pRXsLAdxSVAyGNcqOU2H3Z4Y2dAAGGelL50O0QRMdi1WWeYHdrH/QWpN1e8juWfVKsb8B+pyJwTC+4Gy9w==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.0.tgz", + "integrity": "sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==", "dev": true, "dependencies": { "magic-string": "^0.30.5", @@ -4716,9 +5181,9 @@ "dev": true }, "node_modules/@vitest/spy": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.1.3.tgz", - "integrity": "sha512-Ec0qWyGS5LhATFQtldvChPTAHv08yHIOZfiNcjwRQbFPHpkih0md9KAbs7TfeIfL7OFKoe7B/6ukBTqByubXkQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.0.tgz", + "integrity": "sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==", "dev": true, "dependencies": { "tinyspy": "^2.2.0" @@ -4727,36 +5192,11 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/ui": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-1.1.2.tgz", - "integrity": "sha512-l+fPKIJWEwBHP1TUnBKkCVxWG26/LAc5VIkXFOyKaz/NWoHQBuNa4OArLMsREHYo5EhozzbKQMdZiJVPxjcajA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@vitest/utils": "1.1.2", - "fast-glob": "^3.3.2", - "fflate": "^0.8.1", - "flatted": "^3.2.9", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "sirv": "^2.0.4" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "^1.0.0" - } - }, "node_modules/@vitest/utils": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.1.2.tgz", - "integrity": "sha512-QrXfDieptshDkTkXnA+HmlVQto1h0jengbkSKcJjlbCMeXbSCr3AcALPPzozRQxEOKvFjqx9WHjljz62uxrGew==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.0.tgz", + "integrity": "sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "diff-sequences": "^29.6.3", "estree-walker": "^3.0.3", @@ -4772,8 +5212,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=10" }, @@ -4781,13 +5219,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@vitest/utils/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/@vitest/utils/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -4801,9 +5246,12 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true, - "optional": true, - "peer": true + "dev": true + }, + "node_modules/@xobotyi/scrollbar-width": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", + "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==" }, "node_modules/accepts": { "version": "1.3.8", @@ -4819,9 +5267,9 @@ } }, "node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -4840,9 +5288,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz", - "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true, "engines": { "node": ">=0.4.0" @@ -4913,18 +5361,14 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dependencies": { - "color-convert": "^2.0.1" + "color-convert": "^1.9.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=4" } }, "node_modules/any-promise": { @@ -4967,13 +5411,16 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5013,17 +5460,36 @@ "node": ">=8" } }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", - "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "node_modules/array.prototype.filter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.3.tgz", + "integrity": "sha512-VizNcj/RGJiUyQBgzwxzE5oHdeuXY5hSbbmKMlphj1cy1Vl7Pn2asCGbSrru6hSQjmCzqTBPVWAF/whmEOVHbw==", "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.2.1" + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.4.tgz", + "integrity": "sha512-hzvSHUshSpCflDR1QMUBLHGHP1VIEBegT4pix9H/Z92Xw3ySoy6c2qh7lJWTJnRJ8JCZ9bJNCgTyYaJGcJu6xQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -5069,30 +5535,31 @@ } }, "node_modules/array.prototype.tosorted": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.2.tgz", - "integrity": "sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.3.tgz", + "integrity": "sha512-/DdH4TiTmOKzyQbp/eadcCVexiCb36xJg7HshYOYJnNZFDj33GEv0P7GxsynpShhq4OLYJzbGcBDkLsDt7MnNg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.2.1" + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.1.0", + "es-shim-unscopables": "^1.0.2" } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", - "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "dev": true, "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" }, "engines": { @@ -5129,12 +5596,13 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, "node_modules/autoprefixer": { - "version": "10.4.16", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", - "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", + "version": "10.4.17", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", + "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", "dev": true, "funding": [ { @@ -5151,9 +5619,9 @@ } ], "dependencies": { - "browserslist": "^4.21.10", - "caniuse-lite": "^1.0.30001538", - "fraction.js": "^4.3.6", + "browserslist": "^4.22.2", + "caniuse-lite": "^1.0.30001578", + "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.0", "postcss-value-parser": "^4.2.0" @@ -5169,10 +5637,13 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -5181,236 +5652,76 @@ } }, "node_modules/axe-core": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", - "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/axobject-query": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", - "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", - "dev": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", + "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", "dev": true, - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" + "engines": { + "node": ">=4" } }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "node_modules/axobject-query": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", + "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "dequal": "^2.0.3" } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.7.tgz", - "integrity": "sha512-LidDk/tEGDfuHW2DWh/Hgo4rmnw3cduK6ZkOI1NPFceSK3n/yAGeOsNT7FLnSGHkXj3RHGSEVkN3FsCTY6w2CQ==", + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", + "integrity": "sha512-OtIuQfafSzpo/LhnJaykc0R/MMnuLSSVjVYy9mHArIZ9qTCSZ6TpWCuEKZYVoN//t8HqBNScHrOtCrIK5IaGLg==", "dev": true, "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.4.4", + "@babel/helper-define-polyfill-provider": "^0.5.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz", - "integrity": "sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.9.0.tgz", + "integrity": "sha512-7nZPG1uzK2Ymhy/NbaOWTg3uibM2BmGASS4vHS4szRZAIR8R6GwA/xAujpdrXU5iyklrimWnLWU+BLF9suPTqg==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.4", - "core-js-compat": "^3.33.1" + "@babel/helper-define-polyfill-provider": "^0.5.0", + "core-js-compat": "^3.34.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.4.tgz", - "integrity": "sha512-S/x2iOCvDaCASLYsOOgWOq4bCfKYVqvO/uxjkaYyZ3rVsVE3CeAI/c84NpyuBBymEgNvHgjEot3a9/Z/kXvqsg==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz", + "integrity": "sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.4" + "@babel/helper-define-polyfill-provider": "^0.5.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] + "dependencies": { + "require-from-string": "^2.0.2" + } }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -5421,17 +5732,6 @@ "node": ">=8" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -5465,18 +5765,6 @@ "ms": "2.0.0" } }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -5512,9 +5800,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "funding": [ { "type": "opencollective", @@ -5530,8 +5818,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, @@ -5542,49 +5830,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -5615,14 +5860,19 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dev": true, "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5657,9 +5907,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001568", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001568.tgz", - "integrity": "sha512-vSUkH84HontZJ88MiNrOau1EBrCqEQYgkC5gIySiDlpsm8sGVrhU7Kx4V6h0tnqaHzIHZv08HlJIwPbL4XL9+A==", + "version": "1.0.30001588", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001588.tgz", + "integrity": "sha512-+hVY9jE44uKLkH0SrUTqxjxqNTOWHsbnQDIKjwkZ3lNTzUUVdBLBGXtj/q5Mp5u98r3droaZAewQuEDzjQdZlQ==", "funding": [ { "type": "opencollective", @@ -5676,9 +5926,9 @@ ] }, "node_modules/chai": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", - "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", @@ -5694,38 +5944,18 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "optional": true, - "peer": true, "engines": { - "node": ">=10" + "node": ">=4" } }, - "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true - }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -5739,16 +5969,10 @@ } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -5761,6 +5985,9 @@ "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { "fsevents": "~2.3.2" } @@ -5792,26 +6019,6 @@ "node": ">=8" } }, - "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -5825,12 +6032,12 @@ } }, "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, "engines": { - "node": ">= 10" + "node": ">= 12" } }, "node_modules/client-only": { @@ -5852,40 +6059,94 @@ "node": ">=12" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=0.8" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "node_modules/cliui/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "optional": true, - "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" + "node": ">=7.0.0" } }, - "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "node_modules/cliui/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "optional": true, - "peer": true + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } }, "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -5893,16 +6154,16 @@ "node": ">=7.0.0" } }, - "node_modules/color-name": { + "node_modules/color/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5911,12 +6172,12 @@ } }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "dev": true, "engines": { - "node": ">= 6" + "node": ">= 10" } }, "node_modules/concat-map": { @@ -5951,27 +6212,27 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/core-js-compat": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.34.0.tgz", - "integrity": "sha512-4ZIyeNbW/Cn1wkMMDy+mvrRUxrwFNjKwbhCfQpDd+eLgYipDqp8oGFGtLmhh18EDPKA0g3VUBYOxQGGwvWLVpA==", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.0.tgz", + "integrity": "sha512-iV9Pd/PsgjNWBXeq8XRtWVSgz2tKAfhfvBs7qxYty+RlRd+OCksaWmOnc4JKrTc1cToXL1N0s3l/vwlxPtdElw==", "dev": true, "dependencies": { - "browserslist": "^4.22.2" + "browserslist": "^4.22.3" }, "funding": { "type": "opencollective", @@ -6016,29 +6277,6 @@ } } }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -6059,6 +6297,14 @@ "node": ">= 8" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -6152,22 +6398,21 @@ "dev": true }, "node_modules/cssstyle": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", - "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", "dev": true, "dependencies": { "rrweb-cssom": "^0.6.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -6210,22 +6455,6 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, - "node_modules/dedent": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", - "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", - "dev": true, - "optional": true, - "peer": true, - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, "node_modules/deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -6285,30 +6514,21 @@ "node": ">=0.10.0" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-properties": { @@ -6332,6 +6552,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -6364,13 +6585,10 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "optional": true, - "peer": true, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", "engines": { "node": ">=8" } @@ -6430,9 +6648,9 @@ } }, "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true }, "node_modules/dom-serializer": { @@ -6499,6 +6717,12 @@ "tslib": "^2.0.3" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6506,47 +6730,33 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.611", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.611.tgz", - "integrity": "sha512-ZtRpDxrjHapOwxtv+nuth5ByB8clyn8crVynmRNGO3wG3LOp8RTcyZDqwaI6Ng6y8FCK2hVZmJoqwCskKbNMaw==" + "version": "1.4.675", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.675.tgz", + "integrity": "sha512-+1u3F/XPNIdUwv8i1lDxHAxCvNNU0QIqgb1Ycn+Jnng8ITzWSvUqixRSM7NOazJuwhf65IV17f/VbKj8DmL26A==" }, "node_modules/embla-carousel": { - "version": "8.0.0-rc18", - "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.0.0-rc18.tgz", - "integrity": "sha512-MtiatQCt+R/lEKl2D4TyAx2Ba4/gfosQIY+Y/ooZu1yahxTbFLyhGW8aodn0GW2WZ6jO3Qpfx7VuqCPdRV5moQ==" + "version": "8.0.0-rc23", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.0.0-rc23.tgz", + "integrity": "sha512-ybuDHm+udElyH+XpuemS/W+x8ZhB3a/4UzeTBvsoZUxDSty12ch1f2T0CZxGqIs2FfdaofEOmpLMSvuEPVTMCg==" }, "node_modules/embla-carousel-react": { - "version": "8.0.0-rc18", - "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.0.0-rc18.tgz", - "integrity": "sha512-sif+zkOnQIJzZJJQdtE/kDBrlxNt7Vm6zI8i17qT6QYv3dnazPVf4EEfXQ2CZdh7J3UPwnIMS3Q55E1a8uxJ/g==", + "version": "8.0.0-rc23", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.0.0-rc23.tgz", + "integrity": "sha512-EmtIx4oYkBAUi9R31Tg1lh2HCw0Q01bOftXRDhIlNfB+gsDRS76AgeYU+mQc9qW6yeI5C/W5BqtPZU+ymR0E2Q==", "dependencies": { - "embla-carousel": "8.0.0-rc18", - "embla-carousel-reactive-utils": "8.0.0-rc18" + "embla-carousel": "8.0.0-rc23", + "embla-carousel-reactive-utils": "8.0.0-rc23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0" } }, "node_modules/embla-carousel-reactive-utils": { - "version": "8.0.0-rc18", - "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.0.0-rc18.tgz", - "integrity": "sha512-VOFfvhkICz4GKXb/huMTspYVR8mx8C4uDf0Kp+jA9iZNUA4lmlfxxYUr++SwIy1xABycpqML/9hP2tV6Nn0AEQ==", + "version": "8.0.0-rc23", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.0.0-rc23.tgz", + "integrity": "sha512-/NPejNksrw1iWthTtrps5LNj6gJzylvfCuNTh2+P0FLSPbX/+RlT84Ab5qnbSS/vdmEs8daJbVvb5Bol9v0OdQ==", "peerDependencies": { - "embla-carousel": "8.0.0-rc18" - } - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" + "embla-carousel": "8.0.0-rc23" } }, "node_modules/emoji-regex": { @@ -6596,51 +6806,61 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-abstract": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", - "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", - "dev": true, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.2", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.5", - "es-set-tostringtag": "^2.0.1", + "stackframe": "^1.3.4" + } + }, + "node_modules/es-abstract": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.4.tgz", + "integrity": "sha512-vZYJlk2u6qHYxBOTjAeg7qUxHdNfih64Uu2J8QqWgXZ2cri0ZpJAkzDUK/q593+mvKwlxyaxr6F1Q+3LKoQRgg==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.7", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.2", "es-to-primitive": "^1.2.1", "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.2", - "get-symbol-description": "^1.0.0", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0", + "has-property-descriptors": "^1.0.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", - "hasown": "^2.0.0", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "hasown": "^2.0.1", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", "is-callable": "^1.2.7", "is-negative-zero": "^2.0.2", "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", - "is-typed-array": "^1.1.12", + "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "safe-array-concat": "^1.0.1", - "safe-regex-test": "^1.0.0", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.0", + "safe-regex-test": "^1.0.3", "string.prototype.trim": "^1.2.8", "string.prototype.trimend": "^1.0.7", "string.prototype.trimstart": "^1.0.7", - "typed-array-buffer": "^1.0.0", + "typed-array-buffer": "^1.0.1", "typed-array-byte-length": "^1.0.0", "typed-array-byte-offset": "^1.0.0", "typed-array-length": "^1.0.4", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.13" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -6649,6 +6869,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-get-iterator": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", @@ -6670,25 +6917,29 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", - "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.17.tgz", + "integrity": "sha512-lh7BsUqelv4KUbR5a/ZTaGGIMLCjPGPqJ6q+Oq24YP0RdyptX1uzm4vvaqzk7Zx3bpl/76YLTTDj9L7uYQ92oQ==", "dev": true, "dependencies": { "asynciterator.prototype": "^1.0.0", - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "define-properties": "^1.2.1", - "es-abstract": "^1.22.1", - "es-set-tostringtag": "^2.0.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.2.1", + "es-abstract": "^1.22.4", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.2", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", "globalthis": "^1.0.3", - "has-property-descriptors": "^1.0.0", + "has-property-descriptors": "^1.0.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", + "internal-slot": "^1.0.7", "iterator.prototype": "^1.1.2", - "safe-array-concat": "^1.0.1" + "safe-array-concat": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/es-set-tostringtag": { @@ -6732,9 +6983,9 @@ } }, "node_modules/esbuild": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", - "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -6743,35 +6994,35 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.11", - "@esbuild/android-arm": "0.19.11", - "@esbuild/android-arm64": "0.19.11", - "@esbuild/android-x64": "0.19.11", - "@esbuild/darwin-arm64": "0.19.11", - "@esbuild/darwin-x64": "0.19.11", - "@esbuild/freebsd-arm64": "0.19.11", - "@esbuild/freebsd-x64": "0.19.11", - "@esbuild/linux-arm": "0.19.11", - "@esbuild/linux-arm64": "0.19.11", - "@esbuild/linux-ia32": "0.19.11", - "@esbuild/linux-loong64": "0.19.11", - "@esbuild/linux-mips64el": "0.19.11", - "@esbuild/linux-ppc64": "0.19.11", - "@esbuild/linux-riscv64": "0.19.11", - "@esbuild/linux-s390x": "0.19.11", - "@esbuild/linux-x64": "0.19.11", - "@esbuild/netbsd-x64": "0.19.11", - "@esbuild/openbsd-x64": "0.19.11", - "@esbuild/sunos-x64": "0.19.11", - "@esbuild/win32-arm64": "0.19.11", - "@esbuild/win32-ia32": "0.19.11", - "@esbuild/win32-x64": "0.19.11" + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "engines": { "node": ">=6" } @@ -6783,27 +7034,23 @@ "dev": true }, "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.8.0" } }, "node_modules/eslint": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz", - "integrity": "sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", + "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.55.0", + "@eslint/js": "8.56.0", "@humanwhocodes/config-array": "^0.11.13", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -6947,9 +7194,9 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", - "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, "dependencies": { "array-includes": "^3.1.7", @@ -6968,7 +7215,7 @@ "object.groupby": "^1.0.1", "object.values": "^1.1.7", "semver": "^6.3.1", - "tsconfig-paths": "^3.14.2" + "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" @@ -6998,15 +7245,6 @@ "node": ">=0.10.0" } }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz", @@ -7108,15 +7346,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -7145,6 +7374,115 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -7157,24 +7495,9 @@ }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { @@ -7211,13 +7534,9 @@ } }, "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.0" - } + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, "node_modules/esutils": { "version": "2.0.3", @@ -7238,41 +7557,28 @@ } }, "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" }, "engines": { - "node": ">=10" + "node": ">=16.17" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -7355,43 +7661,10 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", - "dev": true - }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/external-editor/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -7433,34 +7706,30 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } + "node_modules/fast-loops": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.3.tgz", + "integrity": "sha512-8EZzEP0eKkEEVX+drtd9mtuQ+/QrlfW/5MlwcwK5Nds6EkZ/tRzEexkzUY2mIssnAyVLT+TKHuRXmFNNXYUd6g==" + }, + "node_modules/fast-shallow-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz", + "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" }, - "node_modules/fb-watchman": { + "node_modules/fastest-stable-stringify": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz", + "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==" + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "bser": "2.1.1" + "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.1.tgz", - "integrity": "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -7476,15 +7745,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -7578,25 +7838,6 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, - "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -7606,10 +7847,27 @@ "is-callable": "^1.1.3" } }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -7732,53 +7990,45 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dev": true, "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, - "optional": true, - "peer": true, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { "node": ">= 0.4" @@ -7837,18 +8087,11 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, "node_modules/globalthis": { @@ -7928,30 +8171,29 @@ } }, "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "dev": true, "engines": { "node": ">= 0.4" @@ -7973,12 +8215,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -7988,9 +8230,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", + "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", "dev": true, "dependencies": { "function-bind": "^1.1.2" @@ -8017,14 +8259,6 @@ "node": ">=18" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -8042,9 +8276,9 @@ } }, "node_modules/http-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", - "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "dependencies": { "agent-base": "^7.1.0", @@ -8055,9 +8289,9 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", - "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "dev": true, "dependencies": { "agent-base": "^7.0.2", @@ -8068,52 +8302,35 @@ } }, "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, - "optional": true, - "peer": true, "engines": { - "node": ">=10.17.0" + "node": ">=16.17.0" } }, + "node_modules/hyphenate-style-name": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", + "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" + }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "safer-buffer": ">= 2.1.2 < 3" }, "engines": { "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", - "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", "dev": true, "engines": { "node": ">= 4" @@ -8134,27 +8351,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8189,53 +8385,22 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "node_modules/inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", - "dev": true, - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/inquirer/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, + "node_modules/inline-style-prefixer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.0.tgz", + "integrity": "sha512-I7GEdScunP1dQ6IM2mQWh6v0mOYdYmH3Bp31UecKdrcUgcURTcctSe1IECdUznSHKSmsHtjrT3CwCPI1pyxfUQ==", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" + "css-in-js-utils": "^3.1.0", + "fast-loops": "^1.1.3" } }, "node_modules/internal-slot": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", - "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.2", + "es-errors": "^1.3.0", "hasown": "^2.0.0", "side-channel": "^1.0.4" }, @@ -8269,14 +8434,16 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8408,18 +8575,7 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6" + "node": ">=8" } }, "node_modules/is-generator-function": { @@ -8449,15 +8605,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-map": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", @@ -8468,9 +8615,9 @@ } }, "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "engines": { "node": ">= 0.4" @@ -8562,14 +8709,12 @@ } }, "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, - "optional": true, - "peer": true, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8606,12 +8751,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dev": true, "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -8620,18 +8765,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -8678,82 +8811,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", - "integrity": "sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/iterator.prototype": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", @@ -8767,327 +8824,116 @@ "set-function-name": "^2.0.1" } }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "@isaacs/cliui": "^8.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=10" + "node": ">=14" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-circus/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "url": "https://github.com/sponsors/isaacs" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jest-circus/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/jest-cli": { + "node_modules/jest-diff": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } } }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" + "color-convert": "^2.0.1" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-config/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "optional": true, - "peer": true, "engines": { - "node": ">=10" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-config/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-config/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-diff/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/jest-diff/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "color-name": "~1.1.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=7.0.0" } }, - "node_modules/jest-diff/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "node_modules/jest-diff/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "node_modules/jest-diff/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "detect-newline": "^3.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-each": { + "node_modules/jest-diff/node_modules/pretty-format": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-each/node_modules/ansi-styles": { + "node_modules/jest-diff/node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=10" }, @@ -9095,47 +8941,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-each/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each/node_modules/react-is": { + "node_modules/jest-diff/node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "node_modules/jest-diff/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "has-flag": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, "node_modules/jest-get-type": { @@ -9147,41 +8968,14 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-leak-detector": { + "node_modules/jest-matcher-utils": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", "dev": true, - "optional": true, - "peer": true, "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" }, @@ -9189,69 +8983,62 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-leak-detector/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "optional": true, - "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=10" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-leak-detector/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-leak-detector/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "node_modules/jest-matcher-utils/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "color-name": "~1.1.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=7.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/jest-matcher-utils/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-matcher-utils/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=8" } }, "node_modules/jest-matcher-utils/node_modules/pretty-format": { @@ -9268,33 +9055,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util/node_modules/ansi-styles": { + "node_modules/jest-matcher-utils/node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", @@ -9306,229 +9067,121 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util/node_modules/react-is": { + "node_modules/jest-matcher-utils/node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "node_modules/jest-matcher-utils/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" + "has-flag": "^4.0.0" }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "optional": true, - "peer": true, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-resolve": { + "node_modules/jest-message-util": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dev": true, - "optional": true, - "peer": true, "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "color-convert": "^2.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "node_modules/jest-message-util/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" + "dependencies": { + "color-name": "~1.1.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=7.0.0" } }, - "node_modules/jest-runtime/node_modules/strip-bom": { + "node_modules/jest-message-util/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-message-util/node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=8" } }, - "node_modules/jest-snapshot": { + "node_modules/jest-message-util/node_modules/pretty-format": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-snapshot/node_modules/ansi-styles": { + "node_modules/jest-message-util/node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=10" }, @@ -9536,30 +9189,24 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/jest-message-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-snapshot/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -9577,116 +9224,74 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "node_modules/jest-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" + "color-convert": "^2.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=10" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "node_modules/jest-util/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" + "color-name": "~1.1.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=7.0.0" } }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "node_modules/jest-util/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-util/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/jest-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=8" } }, "node_modules/jiti": { @@ -9698,22 +9303,10 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jose": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.1.3.tgz", - "integrity": "sha512-GPExOkcMsCLBTi1YetY2LmkoY559fss0+0KVa6kOfb2YFe84nAM7Nm/XzuZozah4iHgmBGrCOHL5/cy670SBRw==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-levenshtein": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", - "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } + "node_modules/js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" }, "node_modules/js-tokens": { "version": "4.0.0", @@ -9732,12 +9325,13 @@ } }, "node_modules/jsdom": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.0.1.tgz", - "integrity": "sha512-2i27vgvlUsGEBO9+/kJQRbtqtm+191b5zAZrU/UezVmnC2dlDAFLgDYJvAEi94T4kjsRKkezEtLQTgsNEsW2lQ==", + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", + "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", "dev": true, "dependencies": { - "cssstyle": "^3.0.0", + "@asamuzakjp/dom-selector": "^2.0.1", + "cssstyle": "^4.0.1", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", "form-data": "^4.0.0", @@ -9745,7 +9339,6 @@ "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.7", "parse5": "^7.1.2", "rrweb-cssom": "^0.6.0", "saxes": "^6.0.0", @@ -9756,7 +9349,7 @@ "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0", - "ws": "^8.14.2", + "ws": "^8.16.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -9806,21 +9399,20 @@ "dev": true }, "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "dependencies": { - "minimist": "^1.2.0" - }, + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": { "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" } }, "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", "dev": true }, "node_modules/jsx-ast-utils": { @@ -9847,17 +9439,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/language-subtag-registry": { "version": "0.3.22", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", @@ -9876,17 +9457,6 @@ "node": ">=0.10" } }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -9914,6 +9484,11 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/load-script": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-script/-/load-script-2.0.0.tgz", + "integrity": "sha512-km6cyoPW4rM22JMGb+SHUKPMZVDpUaMpMAKrv8UHWllIxc/qjgMGHD91nY+5hM+/NFs310OZ2pqQeJKs7HqWPA==" + }, "node_modules/local-pkg": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", @@ -9957,27 +9532,36 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "node_modules/lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==" + }, + "node_modules/lodash.mapkeys": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.mapkeys/-/lodash.mapkeys-4.6.0.tgz", + "integrity": "sha512-0Al+hxpYvONWtg+ZqHpa/GaVzxuN3V7Xeo2p+bY06EaK/n+Y9R7nBePPN2o1LxmL0TWQSwP8LYZ008/hc9JzhA==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/lodash.omit": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==" + }, + "node_modules/lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==" }, "node_modules/loose-envify": { "version": "1.4.0", @@ -10008,14 +9592,11 @@ } }, "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" + "yallist": "^3.0.2" } }, "node_modules/lz-string": { @@ -10028,9 +9609,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -10039,40 +9620,12 @@ "node": ">=12" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -10123,30 +9676,19 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "bin": { - "mime": "cli.js" + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=4" + "node": ">=8.6" } }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -10155,6 +9697,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -10163,12 +9706,15 @@ } }, "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/min-indent": { @@ -10201,27 +9747,25 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mlly": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz", - "integrity": "sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==", + "node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", "dev": true, - "dependencies": { - "acorn": "^8.10.0", - "pathe": "^1.1.1", - "pkg-types": "^1.0.3", - "ufo": "^1.3.0" + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "node_modules/mlly": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.5.0.tgz", + "integrity": "sha512-NPVQvAY1xr1QoVeG0cy8yUYC7FQcOx6evl/RjT1wL5FvzPnzOysoqB/jmx/DhssT2dYa8nxECLAaFI/+gVLhDQ==", "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=10" + "dependencies": { + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.0.3", + "ufo": "^1.3.2" } }, "node_modules/ms": { @@ -10230,33 +9774,29 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/msw": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.0.11.tgz", - "integrity": "sha512-dAXFS2DxZX0uFqMPhS3oUAu8S/5IQ5qKKSwtXl3/dMTeML0C8JfSvbeWtowYg6pu4Iehgp5L/pHLrlIcG++y/A==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.2.1.tgz", + "integrity": "sha512-DCsZAQwan+2onEcpD86fiEnCKW4IvYzqcwDq/2TIoeNrmBqNp/mJW4wHQyxcoYrRPwgujin7wDFflqiSO1iT/w==", "dev": true, "hasInstallScript": true, "dependencies": { "@bundled-es-modules/cookie": "^2.0.0", - "@bundled-es-modules/js-levenshtein": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", + "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.25.13", + "@mswjs/interceptors": "^0.25.16", "@open-draft/until": "^2.1.0", - "@types/cookie": "^0.4.1", - "@types/js-levenshtein": "^1.1.1", - "@types/statuses": "^2.0.1", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", "chalk": "^4.1.2", - "chokidar": "^3.4.2", "graphql": "^16.8.1", - "headers-polyfill": "^4.0.1", - "inquirer": "^8.2.0", + "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", - "js-levenshtein": "^1.1.6", - "outvariant": "^1.4.0", + "outvariant": "^1.4.2", "path-to-regexp": "^6.2.0", - "strict-event-emitter": "^0.5.0", - "type-fest": "^2.19.0", - "yargs": "^17.3.1" + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.9.0", + "yargs": "^17.7.2" }, "bin": { "msw": "cli/index.js" @@ -10269,7 +9809,7 @@ "url": "https://opencollective.com/mswjs" }, "peerDependencies": { - "typescript": ">= 4.7.x <= 5.2.x" + "typescript": ">= 4.7.x <= 5.3.x" }, "peerDependenciesMeta": { "typescript": { @@ -10277,30 +9817,91 @@ } } }, - "node_modules/msw/node_modules/@types/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", - "dev": true + "node_modules/msw/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } }, - "node_modules/msw/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "node_modules/msw/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=12.20" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "node_modules/msw/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/msw/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/msw/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", "dev": true }, + "node_modules/msw/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -10312,6 +9913,50 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nano-css": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.6.1.tgz", + "integrity": "sha512-T2Mhc//CepkTa3X4pUhKgbEheJHYAxD0VptuqFhDbGMUWVV2m+lkNiW/Ieuj35wrfC8Zm0l7HvssQh7zcEttSw==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "css-tree": "^1.1.2", + "csstype": "^3.1.2", + "fastest-stable-stringify": "^2.0.2", + "inline-style-prefixer": "^7.0.0", + "rtl-css-js": "^1.16.1", + "stacktrace-js": "^2.0.2", + "stylis": "^4.3.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/nano-css/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nano-css/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "node_modules/nano-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -10390,49 +10035,6 @@ } } }, - "node_modules/next-auth": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.5.tgz", - "integrity": "sha512-3RafV3XbfIKk6rF6GlLE4/KxjTcuMCifqrmD+98ejFq73SRoj2rmzoca8u764977lH/Q7jo6Xu6yM+Re1Mz/Og==", - "dependencies": { - "@babel/runtime": "^7.20.13", - "@panva/hkdf": "^1.0.2", - "cookie": "^0.5.0", - "jose": "^4.11.4", - "oauth": "^0.9.15", - "openid-client": "^5.4.0", - "preact": "^10.6.3", - "preact-render-to-string": "^5.1.19", - "uuid": "^8.3.2" - }, - "peerDependencies": { - "next": "^12.2.5 || ^13 || ^14", - "nodemailer": "^6.6.5", - "react": "^17.0.2 || ^18", - "react-dom": "^17.0.2 || ^18" - }, - "peerDependenciesMeta": { - "nodemailer": { - "optional": true - } - } - }, - "node_modules/next-auth/node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/next-auth/node_modules/jose": { - "version": "4.15.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", - "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -10469,14 +10071,6 @@ "tslib": "^2.0.3" } }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -10501,17 +10095,30 @@ } }, "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", + "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "path-key": "^3.0.0" + "path-key": "^4.0.0" }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/nth-check": { @@ -10526,43 +10133,14 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nwsapi": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", - "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", - "dev": true - }, - "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" - }, - "node_modules/oauth4webapi": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.4.0.tgz", - "integrity": "sha512-ZWl8ov8HeGVyc9Icl1cag76HvIcDAp23eIIT+UVGir+dEu8BMgMlvZeZwqLVd0P8DqaumH4N+QLQXN69G1QjSA==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -10647,15 +10225,16 @@ } }, "node_modules/object.groupby": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", - "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.2.tgz", + "integrity": "sha512-bzBq58S+x+uo0VjurFT0UktpKHOZmv4/xePiOA1nbB9pMqpGK7rUPNgf+1YC+7mE+0HzhTMqNUuCqvKhj6FnBw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1" + "array.prototype.filter": "^1.0.3", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.0.0" } }, "node_modules/object.hasown": { @@ -10688,14 +10267,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/oidc-token-hash": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", - "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", - "engines": { - "node": "^10.13.0 || >=12.0.0" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -10718,50 +10289,20 @@ } }, "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, "dependencies": { - "mimic-fn": "^2.1.0" + "mimic-fn": "^4.0.0" }, "engines": { - "node": ">=6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openid-client": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", - "integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==", - "dependencies": { - "jose": "^4.15.1", - "lru-cache": "^6.0.0", - "object-hash": "^2.2.0", - "oidc-token-hash": "^5.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/openid-client/node_modules/jose": { - "version": "4.15.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", - "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/openid-client/node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "engines": { - "node": ">= 6" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -10779,38 +10320,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/outvariant": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.2.tgz", @@ -10847,17 +10356,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10940,10 +10438,35 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-scurry": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", + "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "dev": true, + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", "dev": true }, "node_modules/path-type": { @@ -10955,9 +10478,9 @@ } }, "node_modules/pathe": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz", - "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true }, "node_modules/pathval": { @@ -11003,80 +10526,6 @@ "node": ">= 6" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/pkg-types": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", @@ -11088,10 +10537,19 @@ "pathe": "^1.1.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "funding": [ { "type": "opencollective", @@ -11187,12 +10645,15 @@ } }, "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", - "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", + "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", "dev": true, "engines": { "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, "node_modules/postcss-nested": { @@ -11215,9 +10676,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", - "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "version": "6.0.15", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", + "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -11233,26 +10694,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, - "node_modules/preact": { - "version": "10.11.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", - "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/preact-render-to-string": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", - "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", - "dependencies": { - "pretty-format": "^3.8.0" - }, - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11263,36 +10704,46 @@ } }, "node_modules/pretty-format": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", - "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" }, "engines": { - "node": ">= 6" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -11306,11 +10757,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -11326,24 +10772,6 @@ "node": ">=6" } }, - "node_modules/pure-rand": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", - "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "optional": true, - "peer": true - }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -11409,18 +10837,6 @@ "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -11445,11 +10861,40 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, + "node_modules/react-naver-maps": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/react-naver-maps/-/react-naver-maps-0.1.3.tgz", + "integrity": "sha512-J2AD+MMn33NQ0RH3ldn4kG0ehV9Ao+16/w2kw4XilWl/xWI8mu2DnHUJNaxWBu7S0JSEIsFJuUp7JfZ48wH8kw==", + "dependencies": { + "camelcase": "^5.3.1", + "load-script": "^2.0.0", + "lodash.isempty": "^4.4.0", + "lodash.mapkeys": "^4.6.0", + "lodash.omit": "^4.5.0", + "lodash.pick": "^4.4.0", + "lodash.upperfirst": "^4.3.1", + "prop-types": "^15.7.2", + "react-use": "^17.3.1", + "suspend-react": "^0.0.8" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-naver-maps/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -11459,6 +10904,40 @@ "node": ">=0.10.0" } }, + "node_modules/react-universal-interface": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz", + "integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==", + "peerDependencies": { + "react": "*", + "tslib": "*" + } + }, + "node_modules/react-use": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/react-use/-/react-use-17.5.0.tgz", + "integrity": "sha512-PbfwSPMwp/hoL847rLnm/qkjg3sTRCvn6YhUZiHaUa3FA6/aNoFX79ul5Xt70O1rK+9GxSVqkY0eTwMdsR/bWg==", + "dependencies": { + "@types/js-cookie": "^2.2.6", + "@xobotyi/scrollbar-width": "^1.9.5", + "copy-to-clipboard": "^3.3.1", + "fast-deep-equal": "^3.1.3", + "fast-shallow-equal": "^1.0.0", + "js-cookie": "^2.2.1", + "nano-css": "^5.6.1", + "react-universal-interface": "^0.6.2", + "resize-observer-polyfill": "^1.5.1", + "screenfull": "^5.1.0", + "set-harmonic-interval": "^1.0.1", + "throttle-debounce": "^3.0.1", + "ts-easing": "^0.2.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -11468,20 +10947,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -11508,15 +10973,16 @@ } }, "node_modules/reflect.getprototypeof": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", - "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.5.tgz", + "integrity": "sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.0.0", + "get-intrinsic": "^1.2.3", "globalthis": "^1.0.3", "which-builtin-type": "^1.1.3" }, @@ -11546,9 +11012,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regenerator-transform": { "version": "0.15.2", @@ -11560,14 +11026,15 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", - "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "set-function-name": "^2.0.0" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -11623,12 +11090,26 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -11646,31 +11127,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -11688,30 +11144,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -11738,9 +11170,12 @@ } }, "node_modules/rollup": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.2.tgz", - "integrity": "sha512-66RB8OtFKUTozmVEh3qyNfH+b+z2RXBVloqO2KCC/pjFaGaHtxP9fVfOQKPSGXg2mElmjmxjW/fZ7iKrEpMH5Q==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.0.tgz", + "integrity": "sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==", + "dependencies": { + "@types/estree": "1.0.5" + }, "bin": { "rollup": "dist/bin/rollup" }, @@ -11749,19 +11184,19 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.9.2", - "@rollup/rollup-android-arm64": "4.9.2", - "@rollup/rollup-darwin-arm64": "4.9.2", - "@rollup/rollup-darwin-x64": "4.9.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.2", - "@rollup/rollup-linux-arm64-gnu": "4.9.2", - "@rollup/rollup-linux-arm64-musl": "4.9.2", - "@rollup/rollup-linux-riscv64-gnu": "4.9.2", - "@rollup/rollup-linux-x64-gnu": "4.9.2", - "@rollup/rollup-linux-x64-musl": "4.9.2", - "@rollup/rollup-win32-arm64-msvc": "4.9.2", - "@rollup/rollup-win32-ia32-msvc": "4.9.2", - "@rollup/rollup-win32-x64-msvc": "4.9.2", + "@rollup/rollup-android-arm-eabi": "4.12.0", + "@rollup/rollup-android-arm64": "4.12.0", + "@rollup/rollup-darwin-arm64": "4.12.0", + "@rollup/rollup-darwin-x64": "4.12.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.12.0", + "@rollup/rollup-linux-arm64-gnu": "4.12.0", + "@rollup/rollup-linux-arm64-musl": "4.12.0", + "@rollup/rollup-linux-riscv64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-musl": "4.12.0", + "@rollup/rollup-win32-arm64-msvc": "4.12.0", + "@rollup/rollup-win32-ia32-msvc": "4.12.0", + "@rollup/rollup-win32-x64-msvc": "4.12.0", "fsevents": "~2.3.2" } }, @@ -11771,10 +11206,18 @@ "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", "dev": true }, + "node_modules/rtl-css-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz", + "integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", + "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", "dev": true, "engines": { "node": ">=0.12.0" @@ -11803,23 +11246,14 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", - "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", + "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", "has-symbols": "^1.0.3", "isarray": "^2.0.5" }, @@ -11851,15 +11285,18 @@ ] }, "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", "is-regex": "^1.1.4" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11890,19 +11327,23 @@ "loose-envify": "^1.1.0" } }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" + "node_modules/screenfull": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz", + "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==", + "engines": { + "node": ">=0.10.0" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/send": { @@ -11944,6 +11385,18 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11966,15 +11419,17 @@ } }, "node_modules/set-function-length": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", - "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", "dev": true, "dependencies": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -11994,12 +11449,89 @@ "node": ">= 0.4" } }, + "node_modules/set-harmonic-interval": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz", + "integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==", + "engines": { + "node": ">=6.9" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "dev": true }, + "node_modules/sharp": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.2.tgz", + "integrity": "sha512-WlYOPyyPDiiM07j/UO+E720ju6gtNtHjEGg5vovUk1Lgxyjm2LFO+37Nt/UI3MMh2l6hxTWQWi7qk3cXJTutcQ==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.1", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.2", + "@img/sharp-darwin-x64": "0.33.2", + "@img/sharp-libvips-darwin-arm64": "1.0.1", + "@img/sharp-libvips-darwin-x64": "1.0.1", + "@img/sharp-libvips-linux-arm": "1.0.1", + "@img/sharp-libvips-linux-arm64": "1.0.1", + "@img/sharp-libvips-linux-s390x": "1.0.1", + "@img/sharp-libvips-linux-x64": "1.0.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1", + "@img/sharp-libvips-linuxmusl-x64": "1.0.1", + "@img/sharp-linux-arm": "0.33.2", + "@img/sharp-linux-arm64": "0.33.2", + "@img/sharp-linux-s390x": "0.33.2", + "@img/sharp-linux-x64": "0.33.2", + "@img/sharp-linuxmusl-arm64": "0.33.2", + "@img/sharp-linuxmusl-x64": "0.33.2", + "@img/sharp-wasm32": "0.33.2", + "@img/sharp-win32-ia32": "0.33.2", + "@img/sharp-win32-x64": "0.33.2" + } + }, + "node_modules/sharp/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12022,14 +11554,18 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", + "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12042,34 +11578,29 @@ "dev": true }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, "engines": { - "node": ">= 10" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "optional": true, - "peer": true + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, "node_modules/slash": { "version": "3.0.0", @@ -12090,12 +11621,9 @@ } }, "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", "engines": { "node": ">=0.10.0" } @@ -12108,26 +11636,14 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "optional": true, - "peer": true, + "node_modules/stack-generator": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", + "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" + "stackframe": "^1.3.4" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -12155,6 +11671,30 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" + }, + "node_modules/stacktrace-gps": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", + "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", + "dependencies": { + "source-map": "0.5.6", + "stackframe": "^1.3.4" + } + }, + "node_modules/stacktrace-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", + "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", + "dependencies": { + "error-stack-parser": "^2.0.6", + "stack-generator": "^2.0.5", + "stacktrace-gps": "^3.0.4" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -12196,31 +11736,22 @@ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", "dev": true }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/string-width": { + "node_modules/string-width-cjs": { + "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", @@ -12234,6 +11765,12 @@ "node": ">=8" } }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -12317,6 +11854,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -12327,14 +11877,15 @@ } }, "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, - "optional": true, - "peer": true, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-indent": { @@ -12362,17 +11913,23 @@ } }, "node_modules/strip-literal": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", - "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.0.0.tgz", + "integrity": "sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==", "dev": true, "dependencies": { - "acorn": "^8.10.0" + "js-tokens": "^8.0.2" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz", + "integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==", + "dev": true + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -12395,15 +11952,20 @@ } } }, + "node_modules/stylis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.1.tgz", + "integrity": "sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==" + }, "node_modules/sucrase": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", - "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "7.1.6", + "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", @@ -12414,39 +11976,73 @@ "sucrase-node": "bin/sucrase-node" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" } }, "node_modules/sucrase/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", "dev": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -12461,23 +12057,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.0.8.tgz", + "integrity": "sha512-ZC3r8Hu1y0dIThzsGw0RLZplnX9yXwfItcvaIzJc2VQVi8TGyGDlu92syMB5ulybfvGLHAI5Ghzlk23UBPF8xg==", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/svg-parser": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" }, "node_modules/svgo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.1.0.tgz", - "integrity": "sha512-R5SnNA89w1dYgNv570591F66v34b3eQShpIBcQtZtM5trJwm1VvxbIoMpRYY3ybTAutcKTLEmTsdnaknOHbiQA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz", + "integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==", "dev": true, "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", - "css-tree": "^2.2.1", + "css-tree": "^2.3.1", "css-what": "^6.1.0", - "csso": "5.0.5", + "csso": "^5.0.5", "picocolors": "^1.0.0" }, "bin": { @@ -12491,15 +12095,6 @@ "url": "https://opencollective.com/svgo" } }, - "node_modules/svgo/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "engines": { - "node": ">= 10" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -12507,9 +12102,9 @@ "dev": true }, "node_modules/tailwindcss": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.6.tgz", - "integrity": "sha512-AKjF7qbbLvLaPieoKeTjG1+FyNZT6KaJMJPFeQyLfIp7l82ggH1fbHJSsYIvnbTFQOlkh+gBYpyby5GT1LIdLw==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", + "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -12543,6 +12138,15 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss/node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -12552,22 +12156,6 @@ "node": ">=6" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -12595,56 +12183,38 @@ "node": ">=0.8" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true + "node_modules/throttle-debounce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz", + "integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==", + "engines": { + "node": ">=10" + } }, "node_modules/tinybench": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.1.tgz", - "integrity": "sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz", + "integrity": "sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==", "dev": true }, "node_modules/tinypool": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.1.tgz", - "integrity": "sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz", + "integrity": "sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==", "dev": true, "engines": { "node": ">=14.0.0" } }, "node_modules/tinyspy": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz", - "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true, "engines": { "node": ">=14.0.0" } }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -12665,6 +12235,11 @@ "node": ">=8.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -12674,17 +12249,6 @@ "node": ">=0.6" } }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -12713,17 +12277,22 @@ } }, "node_modules/ts-api-utils": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", - "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", + "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==", "dev": true, "engines": { - "node": ">=16.13.0" + "node": ">=16" }, "peerDependencies": { "typescript": ">=4.2.0" } }, + "node_modules/ts-easing": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz", + "integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -12780,9 +12349,9 @@ "dev": true }, "node_modules/tsconfig-paths": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", - "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "dependencies": { "@types/json5": "^0.0.29", @@ -12791,6 +12360,18 @@ "strip-bom": "^3.0.0" } }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -12818,12 +12399,12 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.2.tgz", + "integrity": "sha512-anpAG63wSpdEbLwOqH8L84urkL6PiVIov3EMmgIhhThevh9aiMQov+6Btx0wldNcvm4wV+e2/Rt1QdDwKHFbHw==", "dev": true, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12843,14 +12424,14 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -12875,16 +12456,17 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.1.tgz", + "integrity": "sha512-tcqKMrTRXjqvHN9S3553NPCaGL0VPgFI92lXszmrE8DMhiDPLBYLlvo8Uu4WZAAX/aGqp/T1sbA4ph8EWjDF9Q==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.7", "for-each": "^0.3.3", + "gopd": "^1.0.1", "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -12908,9 +12490,9 @@ } }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "devOptional": true, "bin": { "tsc": "bin/tsc", @@ -12921,9 +12503,9 @@ } }, "node_modules/ufo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", - "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.4.0.tgz", + "integrity": "sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==", "dev": true }, "node_modules/unbox-primitive": { @@ -13068,36 +12650,12 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true }, - "node_modules/v8-to-istanbul": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", - "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -13108,12 +12666,12 @@ } }, "node_modules/vite": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.10.tgz", - "integrity": "sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", + "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", "dependencies": { "esbuild": "^0.19.3", - "postcss": "^8.4.32", + "postcss": "^8.4.35", "rollup": "^4.2.0" }, "bin": { @@ -13128,319 +12686,137 @@ "optionalDependencies": { "fsevents": "~2.3.3" }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.1.3.tgz", - "integrity": "sha512-BLSO72YAkIUuNrOx+8uznYICJfTEbvBAmWClY3hpath5+h1mbPS5OMn42lrTxXuyCazVyZoDkSRnju78GiVCqA==", - "dev": true, - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-plugin-svgr": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.2.0.tgz", - "integrity": "sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==", - "dependencies": { - "@rollup/pluginutils": "^5.0.5", - "@svgr/core": "^8.1.0", - "@svgr/plugin-jsx": "^8.1.0" - }, - "peerDependencies": { - "vite": "^2.6.0 || 3 || 4 || 5" - } - }, - "node_modules/vitest": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.1.3.tgz", - "integrity": "sha512-2l8om1NOkiA90/Y207PsEvJLYygddsOyr81wLQ20Ra8IlLKbyQncWsGZjnbkyG2KwwuTXLQjEPOJuxGMG8qJBQ==", - "dev": true, - "dependencies": { - "@vitest/expect": "1.1.3", - "@vitest/runner": "1.1.3", - "@vitest/snapshot": "1.1.3", - "@vitest/spy": "1.1.3", - "@vitest/utils": "1.1.3", - "acorn-walk": "^8.3.1", - "cac": "^6.7.14", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^1.3.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.1", - "vite": "^5.0.0", - "vite-node": "1.1.3", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "^1.0.0", - "@vitest/ui": "^1.0.0", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/@vitest/utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.1.3.tgz", - "integrity": "sha512-Dyt3UMcdElTll2H75vhxfpZu03uFpXRCHxWnzcrFjZxT1kTbq8ALUYIeBgGolo1gldVdI0YSlQRacsqxTwNqwg==", - "dev": true, - "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/vitest/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/vitest/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/vitest/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } } }, - "node_modules/vitest/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "node_modules/vite-node": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.0.tgz", + "integrity": "sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==", "dev": true, "dependencies": { - "mimic-fn": "^4.0.0" + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" }, - "engines": { - "node": ">=12" + "bin": { + "vite-node": "vite-node.mjs" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, "engines": { - "node": ">=12" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/vitest/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, + "node_modules/vite-plugin-svgr": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.2.0.tgz", + "integrity": "sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@rollup/pluginutils": "^5.0.5", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependencies": { + "vite": "^2.6.0 || 3 || 4 || 5" } }, - "node_modules/vitest/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, - "node_modules/vitest/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/vitest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.0.tgz", + "integrity": "sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==", "dev": true, - "engines": { - "node": ">=14" + "dependencies": { + "@vitest/expect": "1.3.0", + "@vitest/runner": "1.3.0", + "@vitest/snapshot": "1.3.0", + "@vitest/spy": "1.3.0", + "@vitest/utils": "1.3.0", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.2", + "vite": "^5.0.0", + "vite-node": "1.3.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/vitest/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, "engines": { - "node": ">=12" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.3.0", + "@vitest/ui": "1.3.0", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, "node_modules/w3c-xmlserializer": { @@ -13455,17 +12831,6 @@ "node": ">=18" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "makeerror": "1.0.12" - } - }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -13478,15 +12843,6 @@ "node": ">=10.13.0" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -13508,6 +12864,18 @@ "node": ">=18" } }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -13603,16 +12971,16 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", + "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.5", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "has-tostringtag": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -13638,6 +13006,21 @@ } }, "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", @@ -13654,27 +13037,78 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=7.0.0" } }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, "node_modules/ws": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", @@ -13721,9 +13155,9 @@ } }, "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { "version": "2.3.4", @@ -13781,6 +13215,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 0f460e4b9..bf883c981 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,6 +2,7 @@ "name": "frontend", "version": "0.1.0", "private": true, + "proxy": "https://openapi.naver.com", "scripts": { "dev": "next dev", "build": "next build", @@ -11,14 +12,18 @@ "mock": "npx tsx watch ./src/mocks/server.ts" }, "dependencies": { - "@auth/core": "^0.18.5", - "axios": "^1.6.2", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-toast": "^1.1.5", "embla-carousel-react": "^8.0.0-rc18", "next": "14.0.4", - "next-auth": "^4.24.5", "react": "^18", "react-dom": "^18", - "vite-plugin-svgr": "^4.2.0" + "react-naver-maps": "^0.1.3", + "sharp": "^0.33.2", + "vite-plugin-svgr": "^4.2.0", + "zod": "^3.22.4" }, "devDependencies": { "@mswjs/http-middleware": "^0.9.2", @@ -28,24 +33,25 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.11", + "@types/navermaps": "^3.7.4", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.0.1", + "cors": "^2.8.5", "eslint": "^8", "eslint-config-next": "14.0.4", + "express": "^4.18.2", "jsdom": "^23.0.1", "msw": "^2.0.11", "postcss": "^8", "tailwindcss": "^3.3.0", "ts-node": "^10.9.2", "typescript": "^5", - "vitest": "^1.1.3", - "cors": "^2.8.5", - "express": "^4.18.2" + "vitest": "^1.1.3" }, "msw": { "workerDirectory": "public" } -} \ No newline at end of file +} diff --git a/frontend/public/fonts/Jalnan2TTF.ttf b/frontend/public/fonts/Jalnan2TTF.ttf deleted file mode 100644 index 8357acca7..000000000 Binary files a/frontend/public/fonts/Jalnan2TTF.ttf and /dev/null differ diff --git a/frontend/public/fonts/LICENSE.txt b/frontend/public/fonts/LICENSE.txt new file mode 100644 index 000000000..497b88f9f --- /dev/null +++ b/frontend/public/fonts/LICENSE.txt @@ -0,0 +1,94 @@ +Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), +with Reserved Font Name Pretendard. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/public/fonts/PretendardVariable.woff2 b/frontend/public/fonts/PretendardVariable.woff2 new file mode 100644 index 000000000..49c54b515 Binary files /dev/null and b/frontend/public/fonts/PretendardVariable.woff2 differ diff --git a/frontend/public/icons/bell.svg b/frontend/public/icons/bell.svg index b3992f106..49abe704e 100644 --- a/frontend/public/icons/bell.svg +++ b/frontend/public/icons/bell.svg @@ -1,4 +1,4 @@ - + diff --git a/frontend/public/icons/bookmark.svg b/frontend/public/icons/bookmark.svg new file mode 100644 index 000000000..6d2bf530e --- /dev/null +++ b/frontend/public/icons/bookmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/calendar.svg b/frontend/public/icons/calendar.svg new file mode 100644 index 000000000..3ea1ac988 --- /dev/null +++ b/frontend/public/icons/calendar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/icons/chevron-down.svg b/frontend/public/icons/chevron-down.svg new file mode 100644 index 000000000..662bf7025 --- /dev/null +++ b/frontend/public/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/chevron-left.svg b/frontend/public/icons/chevron-left.svg new file mode 100644 index 000000000..304448223 --- /dev/null +++ b/frontend/public/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/chevron-right.svg b/frontend/public/icons/chevron-right.svg new file mode 100644 index 000000000..c0bf33dce --- /dev/null +++ b/frontend/public/icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/comment.svg b/frontend/public/icons/comment.svg new file mode 100644 index 000000000..8c93e57c4 --- /dev/null +++ b/frontend/public/icons/comment.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/crosshair.svg b/frontend/public/icons/crosshair.svg new file mode 100644 index 000000000..4148a135e --- /dev/null +++ b/frontend/public/icons/crosshair.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/icons/filled-map-pin.svg b/frontend/public/icons/filled-map-pin.svg new file mode 100644 index 000000000..d991a71a8 --- /dev/null +++ b/frontend/public/icons/filled-map-pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/hamburger.svg b/frontend/public/icons/hamburger.svg new file mode 100644 index 000000000..ed5ae45c2 --- /dev/null +++ b/frontend/public/icons/hamburger.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/icons/heart.svg b/frontend/public/icons/heart.svg index 249f21a5e..b3debf9c8 100644 --- a/frontend/public/icons/heart.svg +++ b/frontend/public/icons/heart.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/public/icons/image.svg b/frontend/public/icons/image.svg new file mode 100644 index 000000000..28420a5bb --- /dev/null +++ b/frontend/public/icons/image.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/info.svg b/frontend/public/icons/info.svg new file mode 100644 index 000000000..80a1ed47c --- /dev/null +++ b/frontend/public/icons/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/lock-white.svg b/frontend/public/icons/lock-white.svg new file mode 100644 index 000000000..e8acb7893 --- /dev/null +++ b/frontend/public/icons/lock-white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/lock.svg b/frontend/public/icons/lock.svg new file mode 100644 index 000000000..438107474 --- /dev/null +++ b/frontend/public/icons/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/map-pin.svg b/frontend/public/icons/map-pin.svg new file mode 100644 index 000000000..41b547fa0 --- /dev/null +++ b/frontend/public/icons/map-pin.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/public/icons/plus.svg b/frontend/public/icons/plus.svg new file mode 100644 index 000000000..19c2ff3de --- /dev/null +++ b/frontend/public/icons/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/profile.svg b/frontend/public/icons/profile.svg new file mode 100644 index 000000000..18149d4b5 --- /dev/null +++ b/frontend/public/icons/profile.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/icons/review.svg b/frontend/public/icons/review.svg new file mode 100644 index 000000000..0f4f90e6c --- /dev/null +++ b/frontend/public/icons/review.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/search.svg b/frontend/public/icons/search.svg index 38eab7a39..4976b36a4 100644 --- a/frontend/public/icons/search.svg +++ b/frontend/public/icons/search.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/public/icons/star.svg b/frontend/public/icons/star.svg new file mode 100644 index 000000000..00fbd37f2 --- /dev/null +++ b/frontend/public/icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/trash.svg b/frontend/public/icons/trash.svg new file mode 100644 index 000000000..427a29e7b --- /dev/null +++ b/frontend/public/icons/trash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/icons/unlock.svg b/frontend/public/icons/unlock.svg new file mode 100644 index 000000000..892c13c93 --- /dev/null +++ b/frontend/public/icons/unlock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/x-mark.svg b/frontend/public/icons/x-mark.svg new file mode 100644 index 000000000..74042a013 --- /dev/null +++ b/frontend/public/icons/x-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/x.svg b/frontend/public/icons/x.svg new file mode 100644 index 000000000..91f68b9c9 --- /dev/null +++ b/frontend/public/icons/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/images/placeholder.png b/frontend/public/images/placeholder.png new file mode 100644 index 000000000..3e06ffd42 Binary files /dev/null and b/frontend/public/images/placeholder.png differ diff --git a/frontend/public/loginBtns/kakao_logo.png b/frontend/public/loginBtns/kakao_logo.png deleted file mode 100644 index 49f075953..000000000 Binary files a/frontend/public/loginBtns/kakao_logo.png and /dev/null differ diff --git a/frontend/public/loginBtns/kakao_logo.svg b/frontend/public/loginBtns/kakao_logo.svg new file mode 100644 index 000000000..2651ece38 --- /dev/null +++ b/frontend/public/loginBtns/kakao_logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/logo/header-logo.svg b/frontend/public/logo/header-logo.svg deleted file mode 100644 index 7cfea6059..000000000 --- a/frontend/public/logo/header-logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/logo/yigil_logo.svg b/frontend/public/logo/yigil_logo.svg new file mode 100644 index 000000000..4943af42b --- /dev/null +++ b/frontend/public/logo/yigil_logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js index e369128ec..919d3e1fc 100644 --- a/frontend/public/mockServiceWorker.js +++ b/frontend/public/mockServiceWorker.js @@ -2,13 +2,13 @@ /* tslint:disable */ /** - * Mock Service Worker (2.0.11). + * Mock Service Worker (2.2.1). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. */ -const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39' +const INTEGRITY_CHECKSUM = '223d191a56023cd36aa88c802961b911' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/frontend/src/app/(with-header)/(home)/HomeNavigation.tsx b/frontend/src/app/(with-header)/(home)/HomeNavigation.tsx new file mode 100644 index 000000000..f4eaecf3b --- /dev/null +++ b/frontend/src/app/(with-header)/(home)/HomeNavigation.tsx @@ -0,0 +1,48 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname, useSelectedLayoutSegment } from 'next/navigation'; +import InfoIcon from '/public/icons/info.svg'; +import SearchIcon from '/public/icons/search.svg'; + +export default function HomeNavigation() { + const pathname = usePathname(); + const upperSegment = useSelectedLayoutSegment(); + + const segmentsWhereHomeIsActive = ['search', 'places']; + + const isHomeActive = + upperSegment === null || + segmentsWhereHomeIsActive.some((segment) => + pathname.slice(1).startsWith(segment), + ); + + const selected = 'border-b-4 border-black text-black'; + + return ( +
+ + 홈 + + + 주변 + + + + +
+ {pathname === '/' && ( + + + + )} + + ); +} diff --git a/frontend/src/app/(with-header)/(home)/action.ts b/frontend/src/app/(with-header)/(home)/action.ts new file mode 100644 index 000000000..fbb6ede44 --- /dev/null +++ b/frontend/src/app/(with-header)/(home)/action.ts @@ -0,0 +1,82 @@ +'use server'; + +import z from 'zod'; + +import { cookies } from 'next/headers'; +import { placeSchema, regionSchema } from '@/types/response'; +import { getBaseUrl } from '@/app/utilActions'; + +const placeResponseSchema = z.object({ + places: z.array(placeSchema), +}); + +const regionResponseSchema = z.object({ + regions: z.array(regionSchema), +}); + +async function fetchPopularPlaces(more: 'more' | undefined) { + const BASE_URL = await getBaseUrl(); + const session = cookies().get('SESSION')?.value; + + const endpoint = `${BASE_URL}/v1/places/popular${ + more === 'more' ? '/more' : '' + }`; + + const response = await fetch(endpoint, { + headers: { + Cookie: `SESSION=${session}`, + }, + next: { tags: ['popularPlaces'] }, + }); + + return await response.json(); +} + +async function fetchInterestedRegions() { + const BASE_URL = await getBaseUrl(); + + const session = cookies().get('SESSION')?.value; + + const response = await fetch(`${BASE_URL}/v1/regions/my`, { + headers: { + Cookie: `SESSION=${session}`, + }, + next: { tags: ['interestedRegions'] }, + }); + + return await response.json(); +} + +async function fetchRegionPlaces(id: number) { + const BASE_URL = await getBaseUrl(); + + const response = await fetch(`${BASE_URL}/v1/places/region/${id}`, { + next: { tags: ['regionPlaces'] }, + }); + + return await response.json(); +} + +export async function getPopularPlaces(more: 'more' | undefined = 'more') { + const json = await fetchPopularPlaces(more); + + const result = placeResponseSchema.safeParse(json); + + return result; +} + +export async function getInterestedRegions() { + const json = await fetchInterestedRegions(); + + const result = regionResponseSchema.safeParse(json); + + return result; +} + +export async function getRegionPlaces(id: number) { + const json = await fetchRegionPlaces(id); + + const result = placeResponseSchema.safeParse(json); + + return result; +} diff --git a/frontend/src/app/(with-header)/(home)/layout.tsx b/frontend/src/app/(with-header)/(home)/layout.tsx new file mode 100644 index 000000000..6cb9812f4 --- /dev/null +++ b/frontend/src/app/(with-header)/(home)/layout.tsx @@ -0,0 +1,12 @@ +import HomeNavigation from './HomeNavigation'; + +import type { ReactNode } from 'react'; + +export default function HomeLayout({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/src/app/(with-header)/(home)/nearby/page.tsx b/frontend/src/app/(with-header)/(home)/nearby/page.tsx new file mode 100644 index 000000000..5e928ea3c --- /dev/null +++ b/frontend/src/app/(with-header)/(home)/nearby/page.tsx @@ -0,0 +1,13 @@ +import MapComponent from '@/app/_components/naver-map/MapComponent'; +import ViewTravelMap from '@/app/_components/near/ViewTravelMap'; +import React from 'react'; + +export default function NearbyPage() { + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/app/(with-header)/(home)/page.tsx b/frontend/src/app/(with-header)/(home)/page.tsx new file mode 100644 index 000000000..545ffd22b --- /dev/null +++ b/frontend/src/app/(with-header)/(home)/page.tsx @@ -0,0 +1,115 @@ +export const dynamic = 'force-dynamic'; + +import { homePopOverData } from '@/app/_components/ui/popover/constants'; +import FloatingActionButton from '@/app/_components/FloatingActionButton'; +import { PopularPlaces, RegionPlaces } from '@/app/_components/place'; +import DummyPlaces from '@/app/_components/place/dummy/DummyPlaces'; + +import { + getInterestedRegions, + getPopularPlaces, + getRegionPlaces, +} from './action'; + +import PlusIcon from '@/../public/icons/plus.svg'; +import { myInfoSchema } from '@/types/response'; +import { authenticateUser } from '@/app/_components/mypage/hooks/authenticateUser'; + +function OpenedFABIcon() { + return ; +} + +function ClosedFABIcon() { + return ; +} + +export default async function HomePage({ + searchParams, +}: { + searchParams: { name: string }; +}) { + const popularPlacesResult = await getPopularPlaces(); + + if (!popularPlacesResult.success) { + console.log(popularPlacesResult.error.errors); + + return
Failed to get popular places
; + } + + const popularPlaces = popularPlacesResult.data.places; + + const memberJson = await authenticateUser(); + const memberInfo = myInfoSchema.safeParse(memberJson); + + if (!memberInfo.success) { + return ( +
+ + + } + closedIcon={} + /> +
+ ); + } + + const interestedRegions = await getInterestedRegions(); + + if (!interestedRegions.success) { + return
Failed to get regions
; + } + + const regions = interestedRegions.data.regions; + + if (regions.length === 0) { + return ( +
+ + + } + closedIcon={} + /> +
+ ); + } + + const regionPlacesResult = await getRegionPlaces(regions[0].id); + + if (!regionPlacesResult.success) { + return
Failed to get region places
; + } + + const regionPlaces = regionPlacesResult.data.places; + + return ( +
+ + + } + closedIcon={} + /> +
+ ); +} diff --git a/frontend/src/app/(with-header)/(home)/places/popular/page.tsx b/frontend/src/app/(with-header)/(home)/places/popular/page.tsx new file mode 100644 index 000000000..258b1daeb --- /dev/null +++ b/frontend/src/app/(with-header)/(home)/places/popular/page.tsx @@ -0,0 +1,35 @@ +import { authenticateUser } from '@/app/_components/mypage/hooks/authenticateUser'; +import { myInfoSchema } from '@/types/response'; +import { getPopularPlaces } from '../../action'; +import { Place } from '@/app/_components/place'; + +export default async function PopularPlacesPage() { + const memberJson = await authenticateUser(); + const memberInfo = myInfoSchema.safeParse(memberJson); + + const result = await getPopularPlaces('more'); + + if (!result.success) { + console.log(result); + + return
Failed
; + } + + const places = result.data.places; + + return ( +
+

인기 장소

+
+ {places.map((place, index) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/app/(with-header)/(home)/places/regions/[id]/page.tsx b/frontend/src/app/(with-header)/(home)/places/regions/[id]/page.tsx new file mode 100644 index 000000000..b49780525 --- /dev/null +++ b/frontend/src/app/(with-header)/(home)/places/regions/[id]/page.tsx @@ -0,0 +1,66 @@ +import { authenticateUser } from '@/app/_components/mypage/hooks/authenticateUser'; +import { myInfoSchema } from '@/types/response'; +import { getInterestedRegions, getRegionPlaces } from '../../../action'; +import DummyPlace from '@/app/_components/place/dummy/DummyPlace'; +import Link from 'next/link'; +import { RegionPlaces } from '@/app/_components/place'; + +export default async function RegionsPlacePage({ + params, +}: { + params: { id: string }; +}) { + const memberJson = await authenticateUser(); + const memberInfo = myInfoSchema.safeParse(memberJson); + + const interestedRegions = await getInterestedRegions(); + + if (!interestedRegions.success) { + return
Failed to get interested regions!
; + } + + const regions = interestedRegions.data.regions; + + if (regions.length === 0) { + return ( +
+
+ +
+ + 관심 지역을 설정하시면 +
더 많은 장소를 확인할 수 있습니다. +
+ + 홈으로 + +
+
+
+ ); + } + + const regionId = Number.parseInt(params.id, 10); + + const currentRegion = regions.find((region) => region.id === regionId); + + const regionPlacesResult = await getRegionPlaces(regionId); + + if (!regionPlacesResult.success) { + return
Failed to get region places!
; + } + + const places = regionPlacesResult.data.places; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/(with-header)/(home)/search/page.tsx b/frontend/src/app/(with-header)/(home)/search/page.tsx new file mode 100644 index 000000000..2a0a6307d --- /dev/null +++ b/frontend/src/app/(with-header)/(home)/search/page.tsx @@ -0,0 +1,6 @@ +import SearchBox from '@/app/_components/search'; + +export default function SearchPage() { + // 해결 요망 + // return ; +} diff --git a/frontend/src/app/(with-header)/(mypage)/layout.tsx b/frontend/src/app/(with-header)/(mypage)/layout.tsx new file mode 100644 index 000000000..d487bd93c --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/layout.tsx @@ -0,0 +1,19 @@ +import MyPageContent from '@/app/_components/mypage/routeTabs/MyPageTabs'; +import MyPageInfo from '@/app/_components/mypage/MyPageInfo'; +import MyPageRoutes from '@/app/_components/mypage/routeTabs/MyPageRoutes'; + +export default function MyPageLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> +
+ +
+ + {children} + + ); +} diff --git a/frontend/src/app/(with-header)/(mypage)/mypage/favorite/page.tsx b/frontend/src/app/(with-header)/(mypage)/mypage/favorite/page.tsx new file mode 100644 index 000000000..094fc06ec --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/mypage/favorite/page.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export default function MyPageFavorite() { + return ( +
MyPageFavorite
+ ) +} diff --git a/frontend/src/app/(with-header)/(mypage)/mypage/layout.tsx b/frontend/src/app/(with-header)/(mypage)/mypage/layout.tsx new file mode 100644 index 000000000..b3bc89534 --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/mypage/layout.tsx @@ -0,0 +1,20 @@ +import MyPageContent from '@/app/_components/mypage/routeTabs/MyPageTabs'; +import MyPageInfo from '@/app/_components/mypage/MyPageInfo'; +import type { ReactNode } from 'react'; +import { authenticateUser } from '@/app/_components/mypage/hooks/authenticateUser'; + +export default async function MyPageInformation({ + children, +}: { + children: ReactNode; +}) { + const memberInfo = await authenticateUser(); + + return ( +
+ + + {children} +
+ ); +} diff --git a/frontend/src/app/(with-header)/(mypage)/mypage/my/bookmark/page.tsx b/frontend/src/app/(with-header)/(mypage)/mypage/my/bookmark/page.tsx new file mode 100644 index 000000000..04fd79f06 --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/mypage/my/bookmark/page.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function MyPageBookMarkPage() { + return
MyPageBookMark
; +} diff --git a/frontend/src/app/(with-header)/(mypage)/mypage/my/follow/page.tsx b/frontend/src/app/(with-header)/(mypage)/mypage/my/follow/page.tsx new file mode 100644 index 000000000..1fcca86cd --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/mypage/my/follow/page.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function FollowPage() { + return
FollowPage
; +} diff --git a/frontend/src/app/(with-header)/(mypage)/mypage/my/follower/page.tsx b/frontend/src/app/(with-header)/(mypage)/mypage/my/follower/page.tsx new file mode 100644 index 000000000..316b0e251 --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/mypage/my/follower/page.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function FollowerPage() { + return
FollowerPage
; +} diff --git a/frontend/src/app/(with-header)/(mypage)/mypage/my/layout.tsx b/frontend/src/app/(with-header)/(mypage)/mypage/my/layout.tsx new file mode 100644 index 000000000..6340e288e --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/mypage/my/layout.tsx @@ -0,0 +1,15 @@ +import MyPagePlace from '@/app/_components/mypage/routeTabs/MyPagePlace'; +import type { ReactNode } from 'react'; + +export default async function MyPageInformation({ + children, +}: { + children: ReactNode; +}) { + return ( + <> + + {children} + + ); +} diff --git a/frontend/src/app/(with-header)/(mypage)/mypage/my/travel/course/page.tsx b/frontend/src/app/(with-header)/(mypage)/mypage/my/travel/course/page.tsx new file mode 100644 index 000000000..09b0eb885 --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/mypage/my/travel/course/page.tsx @@ -0,0 +1,22 @@ +import MyPageCourseList from '@/app/_components/mypage/course/MyPageCourseList'; +import { getMyPageCourses } from '@/app/_components/mypage/hooks/myPageActions'; +import React from 'react'; + +export default async function MyPageMyCourse() { + const courseList = await getMyPageCourses(); + if (!courseList.success) return
failed
; + return ( + <> + {!!courseList.data.content.length ? ( + + ) : ( +
+ 장소를 추가해주세요. +
+ )} + + ); +} diff --git a/frontend/src/app/(with-header)/(mypage)/mypage/my/travel/layout.tsx b/frontend/src/app/(with-header)/(mypage)/mypage/my/travel/layout.tsx new file mode 100644 index 000000000..8787123d9 --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/mypage/my/travel/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react'; +import MyPageTravel from '@/app/_components/mypage/routeTabs/MyPageTravel'; + +export default function MyPageTravelLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + <> + + {children} + + ); +} diff --git a/frontend/src/app/(with-header)/(mypage)/mypage/my/travel/spot/page.tsx b/frontend/src/app/(with-header)/(mypage)/mypage/my/travel/spot/page.tsx new file mode 100644 index 000000000..5483f5b86 --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/mypage/my/travel/spot/page.tsx @@ -0,0 +1,24 @@ +import { getMyPageSpots } from '@/app/_components/mypage/hooks/myPageActions'; +import MyPageSpotList from '@/app/_components/mypage/spot/MyPageSpotList'; + +import React from 'react'; + +export default async function MyPageMySpot() { + const spotList = await getMyPageSpots(); + if (!spotList.success) return
failed
; + + return ( + <> + {!!spotList.data.content.length ? ( + + ) : ( +
+ 장소를 추가해주세요. +
+ )} + + ); +} diff --git a/frontend/src/app/(with-header)/(mypage)/setting/page.tsx b/frontend/src/app/(with-header)/(mypage)/setting/page.tsx new file mode 100644 index 000000000..2786d4d52 --- /dev/null +++ b/frontend/src/app/(with-header)/(mypage)/setting/page.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function SettingPage() { + return
SettingPage
; +} diff --git a/frontend/src/app/(with-header)/add/[slug]/confirm/page.tsx b/frontend/src/app/(with-header)/add/[slug]/confirm/page.tsx new file mode 100644 index 000000000..cfdbc05fe --- /dev/null +++ b/frontend/src/app/(with-header)/add/[slug]/confirm/page.tsx @@ -0,0 +1,11 @@ +import AddConfirmContent from '@/app/_components/add/common/AddConfirmContent'; +import React from 'react'; + +export default function AddConfirmPage() { + return ( +
+ {/** 완료 바 */} + +
+ ); +} diff --git a/frontend/src/app/(with-header)/add/[slug]/page.tsx b/frontend/src/app/(with-header)/add/[slug]/page.tsx new file mode 100644 index 000000000..174a2c969 --- /dev/null +++ b/frontend/src/app/(with-header)/add/[slug]/page.tsx @@ -0,0 +1,5 @@ +import AddSpot from '@/app/_components/add/AddSpot'; + +export default function AddSpotPage() { + return ; +} diff --git a/frontend/src/app/(with-header)/layout.tsx b/frontend/src/app/(with-header)/layout.tsx new file mode 100644 index 000000000..9a3c1fd35 --- /dev/null +++ b/frontend/src/app/(with-header)/layout.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react'; +import '../globals.css'; +import Header from '../_components/header/Header'; + +export default async function WithHeaderLayout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+
+ {children} +
+ ); +} diff --git a/frontend/src/app/(with-header)/place/[id]/@reviews/action.ts b/frontend/src/app/(with-header)/place/[id]/@reviews/action.ts new file mode 100644 index 000000000..f2939e85e --- /dev/null +++ b/frontend/src/app/(with-header)/place/[id]/@reviews/action.ts @@ -0,0 +1,51 @@ +'use server'; + +import { z } from 'zod'; +import { cookies } from 'next/headers'; + +import { getBaseUrl } from '@/app/utilActions'; +import { spotSchema } from '@/types/response'; + +const spotsResponseSchema = z.object({ + spots: z.array(spotSchema), + has_next: z.boolean(), +}); + +async function fetchSpots( + placeId: number, + page: number = 1, + size: number = 5, + sortBy: 'created_at' | 'rate', + sortOrder: 'desc' | 'asc', +) { + const BASE_URL = await getBaseUrl(); + const session = cookies().get('SESSION')?.value; + + const endpoint = `${BASE_URL}/v1/spots/place/${placeId}`; + const queryParams = Object.entries({ page, size, sortBy, sortOrder }) + .map(([key, value]) => `${key}=${value}`) + .join('&'); + + const response = await fetch(`${endpoint}?${queryParams}`, { + headers: { + Cookie: `SESSION=${session}`, + }, + next: { tags: [`spots/${placeId}`] }, + }); + + return await response.json(); +} + +export async function getSpots( + placeId: number, + page: number = 1, + size: number = 5, + sortBy: 'created_at' | 'rate' = 'created_at', + sortOrder: 'desc' | 'asc' = 'desc', +) { + const json = await fetchSpots(placeId, page, size, sortBy, sortOrder); + + const result = spotsResponseSchema.safeParse(json); + + return result; +} diff --git a/frontend/src/app/(with-header)/place/[id]/@reviews/courses/page.tsx b/frontend/src/app/(with-header)/place/[id]/@reviews/courses/page.tsx new file mode 100644 index 000000000..ff6c20dfb --- /dev/null +++ b/frontend/src/app/(with-header)/place/[id]/@reviews/courses/page.tsx @@ -0,0 +1,10 @@ +export default function Courses() { + return ( +
+
+ 🚧 + 준비 중입니다! +
+
+ ); +} diff --git a/frontend/src/app/(with-header)/place/[id]/@reviews/layout.tsx b/frontend/src/app/(with-header)/place/[id]/@reviews/layout.tsx new file mode 100644 index 000000000..ba3d253dd --- /dev/null +++ b/frontend/src/app/(with-header)/place/[id]/@reviews/layout.tsx @@ -0,0 +1,24 @@ +import TabGroup from '@/app/_components/place/TabGroup'; +import type { ReactElement } from 'react'; + +export default function ReviewsLayout({ + params, + children, +}: { + params: { id: number }; + children: ReactElement; +}) { + return ( +
+

+ 리뷰 +

+ + {children} +
+ ); +} diff --git a/frontend/src/app/(with-header)/place/[id]/@reviews/page.tsx b/frontend/src/app/(with-header)/place/[id]/@reviews/page.tsx new file mode 100644 index 000000000..ee5b4d6b7 --- /dev/null +++ b/frontend/src/app/(with-header)/place/[id]/@reviews/page.tsx @@ -0,0 +1,39 @@ +import Spots from '@/app/_components/place/spot/Spots'; +import { getSpots } from './action'; +import { authenticateUser } from '@/app/_components/mypage/hooks/authenticateUser'; +import { myInfoSchema } from '@/types/response'; +import MemberProvider from '@/context/MemberContext'; + +import type { TMemberStatus } from '@/context/MemberContext'; + +export default async function SpotsPage({ + params, +}: { + params: { id: number }; +}) { + const memberJson = await authenticateUser(); + const memberResult = myInfoSchema.safeParse(memberJson); + + const memberStatus: TMemberStatus = memberResult.success + ? { member: memberResult.data, isLoggedIn: 'true' } + : { isLoggedIn: 'false' }; + + const spotsResult = await getSpots(params.id); + + if (!spotsResult.success) { + return
Failed to get spots
; + } + + const { spots, has_next } = spotsResult.data; + + return ( + + + + ); +} diff --git a/frontend/src/app/(with-header)/place/[id]/default.tsx b/frontend/src/app/(with-header)/place/[id]/default.tsx new file mode 100644 index 000000000..bc5e7193a --- /dev/null +++ b/frontend/src/app/(with-header)/place/[id]/default.tsx @@ -0,0 +1,46 @@ +import { authenticateUser } from '@/app/_components/mypage/hooks/authenticateUser'; +import { myInfoSchema } from '@/types/response'; +import { getMySpotForPlace, getPlaceDetail } from '../action'; +import PlaceDetail from '@/app/_components/place/PlaceDetail'; +import PlaceDetailWithMySpot from '@/app/_components/place/PlaceDetailWithMySpot'; + +export default async function PlaceDetailDefault({ + params, +}: { + params: { id: number }; +}) { + const memberJson = await authenticateUser(); + const memberInfo = myInfoSchema.safeParse(memberJson); + + const detailResult = await getPlaceDetail(params.id); + + if (!detailResult.success) { + return
Failed to get place detail!
; + } + + const detail = detailResult.data; + + if (!memberInfo.success) { + return ( +
+ +
+ ); + } + + const mySpotResult = await getMySpotForPlace(params.id); + + if (!mySpotResult.success) { + return
Failed to get my spot for place detail!
; + } + + const mySpot = mySpotResult.data; + + return ( + + ); +} diff --git a/frontend/src/app/(with-header)/place/[id]/layout.tsx b/frontend/src/app/(with-header)/place/[id]/layout.tsx new file mode 100644 index 000000000..b6af205e0 --- /dev/null +++ b/frontend/src/app/(with-header)/place/[id]/layout.tsx @@ -0,0 +1,24 @@ +import BackButton from '@/app/_components/place/BackButton'; +import TabGroup from '@/app/_components/place/TabGroup'; + +import type { ReactElement } from 'react'; + +export default function PlaceDetailLayout({ + children, + reviews, +}: { + children: ReactElement; + reviews: ReactElement; +}) { + return ( +
+ + {children} +
+ {reviews} +
+ ); +} diff --git a/frontend/src/app/(with-header)/place/[id]/page.tsx b/frontend/src/app/(with-header)/place/[id]/page.tsx new file mode 100644 index 000000000..330305c80 --- /dev/null +++ b/frontend/src/app/(with-header)/place/[id]/page.tsx @@ -0,0 +1,46 @@ +import PlaceDetail from '@/app/_components/place/PlaceDetail'; +import { myInfoSchema } from '@/types/response'; +import { authenticateUser } from '@/app/_components/mypage/hooks/authenticateUser'; +import { getMySpotForPlace, getPlaceDetail } from '../action'; +import PlaceMySpot from '@/app/_components/place/spot/PlaceMySpot'; + +export default async function PlaceDetailPage({ + params, +}: { + params: { id: number }; +}) { + const memberJson = await authenticateUser(); + const memberInfo = myInfoSchema.safeParse(memberJson); + + const detailResult = await getPlaceDetail(params.id); + + if (!detailResult.success) { + return
Failed to get place detail!
; + } + + const detail = detailResult.data; + + if (!memberInfo.success) { + return ( +
+ +
+ ); + } + + const mySpotResult = await getMySpotForPlace(params.id); + + if (!mySpotResult.success) { + return
Failed to get my spot for place detail!
; + } + + const mySpot = mySpotResult.data; + + return ( +
+ +
+ +
+ ); +} diff --git a/frontend/src/app/(with-header)/place/action.ts b/frontend/src/app/(with-header)/place/action.ts new file mode 100644 index 000000000..eebc401a7 --- /dev/null +++ b/frontend/src/app/(with-header)/place/action.ts @@ -0,0 +1,50 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { getBaseUrl } from '@/app/utilActions'; +import { mySpotForPlaceSchema, placeDetailSchema } from '@/types/response'; + +async function fetchPlaceDetail(id: number) { + const session = cookies().get('SESSION')?.value; + + const BASE_URL = await getBaseUrl(); + + const response = await fetch(`${BASE_URL}/v1/places/${id}`, { + headers: { + Cookie: `SESSION=${session}`, + }, + next: { tags: [`placeDetail/${id}`] }, + }); + + return await response.json(); +} + +async function fetchMySpotForPlace(id: number) { + const session = cookies().get('SESSION')?.value; + + const BASE_URL = await getBaseUrl(); + + const response = await fetch(`${BASE_URL}/v1/spots/place/${id}/me`, { + headers: { + Cookie: `SESSION=${session}`, + }, + }); + + return await response.json(); +} + +export async function getPlaceDetail(id: number) { + const json = await fetchPlaceDetail(id); + + const result = placeDetailSchema.safeParse(json); + + return result; +} + +export async function getMySpotForPlace(id: number) { + const json = await fetchMySpotForPlace(id); + + const result = mySpotForPlaceSchema.safeParse(json); + + return result; +} diff --git a/frontend/src/app/(without-header)/login/page.tsx b/frontend/src/app/(without-header)/login/page.tsx new file mode 100644 index 000000000..7c318e4e0 --- /dev/null +++ b/frontend/src/app/(without-header)/login/page.tsx @@ -0,0 +1,42 @@ +export const dynamic = 'force-dynamic'; + +import React from 'react'; + +import KakaoBtn from '@/app/_components/ui/button/Kakao'; +import GoogleLoginButton from '@/app/_components/ui/button/GoogleLoginButton'; +import LoginLogo from '/public/logo/yigil_logo.svg'; +import CloseButton from '@/app/_components/ui/button/CloseButton'; + +import { kakaoOAuthEndpoint } from '@/app/endpoints/api/auth/callback/kakao/constants'; +import { googleOAuthEndPoint } from '@/app/endpoints/api/auth/callback/google/constants'; + +export default async function LoginPage() { + const { KAKAO_ID, GOOGLE_CLIENT_ID } = process.env; + + const kakaoHref = await kakaoOAuthEndpoint(KAKAO_ID); + const googleHref = await googleOAuthEndPoint(GOOGLE_CLIENT_ID); + + return ( +
+ +
+ +
+
+
+
+
+ SNS로 간편로그인 +
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/_components/FloatingActionButton.tsx b/frontend/src/app/_components/FloatingActionButton.tsx new file mode 100644 index 000000000..6aab9967d --- /dev/null +++ b/frontend/src/app/_components/FloatingActionButton.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useState } from 'react'; +import PopOver from './ui/popover/PopOver'; + +import type { ReactElement } from 'react'; +import type { TPopOverData } from './ui/popover/types'; + +interface TFloatingActionButton { + popOverData: TPopOverData[]; + backdropStyle?: string; + openedIcon: ReactElement; + closedIcon: ReactElement; +} + +export default function FloatingActionButton({ + popOverData, + backdropStyle, + openedIcon, + closedIcon, +}: TFloatingActionButton) { + const [isModalOpened, setIsModalOpened] = useState(false); + + const closeModal = () => { + setIsModalOpened(false); + }; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/_components/IconWithCounts.tsx b/frontend/src/app/_components/IconWithCounts.tsx new file mode 100644 index 000000000..aa9398fd1 --- /dev/null +++ b/frontend/src/app/_components/IconWithCounts.tsx @@ -0,0 +1,20 @@ +import type { ReactElement } from 'react'; + +export default function IconWithCounts({ + icon, + count, + rating, +}: { + icon: ReactElement; + count: number; + rating?: boolean; +}) { + const label = rating ? count.toFixed(1) : count >= 100 ? '99+' : count; + + return ( +
+ {icon} +

{label}

+
+ ); +} diff --git a/frontend/src/app/_components/LoadingIndicator.tsx b/frontend/src/app/_components/LoadingIndicator.tsx new file mode 100644 index 000000000..4c4852c78 --- /dev/null +++ b/frontend/src/app/_components/LoadingIndicator.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +interface TLoadingIndicator { + style?: string; + size?: string; + backgroundColor?: string; + loadingText: string; +} + +export default function LoadingIndicator({ + style, + size, + backgroundColor, + loadingText, +}: TLoadingIndicator) { + return ( +
+ + {loadingText} +
+ ); +} diff --git a/frontend/src/app/_components/Portal.tsx b/frontend/src/app/_components/Portal.tsx new file mode 100644 index 000000000..bf285af46 --- /dev/null +++ b/frontend/src/app/_components/Portal.tsx @@ -0,0 +1,31 @@ +'use client'; +import React, { ReactNode, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +interface TPortalType { + closeModal: () => void; + backdropStyle?: string; + children: ReactNode; +} +export default function ViewPortal({ + closeModal, + backdropStyle, + children, +}: TPortalType) { + const [element, setElement] = useState(null); + + useEffect(() => { + setElement(document.getElementById('modal')); + }, []); + if (!element) return <>; + + return createPortal( +
+ {children} +
, + element, + ); +} diff --git a/frontend/src/app/_components/add/AddSpot.tsx b/frontend/src/app/_components/add/AddSpot.tsx new file mode 100644 index 000000000..d0fe8755d --- /dev/null +++ b/frontend/src/app/_components/add/AddSpot.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useReducer, useState } from 'react'; + +import { makeInitialStep, reducer, StepNavigation } from './common/step'; +import ProgressIndicator from './common/ProgressIndicator'; +import { + AddSpotContext, + addSpotReducer, + initialAddSpotState, +} from './spot/SpotContext'; + +import * as Common from './common'; + +import SearchBox from '../search'; +import ImageHandler from '../images'; +import MapComponent from '../naver-map/MapComponent'; + +import type { DataInput, Making } from './common/step/types'; +import type { TAddSpotProps } from './spot/SpotContext'; + +import { searchAction } from '../search/action'; +import Alert from '../ui/dialog/Alert'; + +function getAlertText(step: DataInput.TDataInputStep) { + switch (step.data.label) { + case '시작': { + return '지도에서 장소를 선택해주세요!'; + } + case '사진': { + return '사진을 한 장 이상 선택해주세요!'; + } + case '리뷰': { + return '리뷰 내용을 입력해주세요!'; + } + case '주소': + case '순서': + case '별점': + return ''; + } +} + +function canGoNext( + step: DataInput.TDataInputStep, + addSpotState: TAddSpotProps, + currentFoundPlace?: { + name: string; + roadAddress: string; + coords: { lat: number; lng: number }; + }, +) { + const label = step.data.label; + + if ( + label === '시작' && + (currentFoundPlace === undefined || addSpotState.name === '') + ) { + return false; + } + + if (label === '사진' && addSpotState.images.length === 0) { + return false; + } + + if (label === '리뷰' && addSpotState.review.review === '') { + return false; + } + + return true; +} + +export default function AddSpot() { + const [step, dispatchStep] = useReducer( + reducer, + makeInitialStep(true, false), + ); + + const [addSpotState, dispatchSpot] = useReducer( + addSpotReducer, + initialAddSpotState, + ); + + const [searchResults, setSearchResults] = useState< + { name: string; roadAddress: string }[] + >([]); + + const { makingStep, inputStep } = step; + + const makingSpotStep = makingStep as Making.TSpot; + const dataFromNewStep = inputStep as DataInput.TDataFromNew; + + const stepLabel = makingSpotStep.data.label; + const inputLabel = dataFromNewStep.data.label; + + async function search(keyword: string) { + if (keyword === '') { + setSearchResults([]); + return; + } + + const results = await searchAction(keyword); + + if (results.status === 'succeed') { + setSearchResults(results.data); + } + } + + const [alertOpened, setAlertOpened] = useState(false); + + // 검색을 통해 선택한 장소 + const [currentFoundPlace, setCurrentFoundPlace] = useState<{ + name: string; + roadAddress: string; + coords: { lat: number; lng: number }; + }>(); + + return ( +
+ + { + if (!canGoNext(step.inputStep, addSpotState, currentFoundPlace)) { + setAlertOpened(true); + return; + } + + dispatchStep({ type: 'next' }); + }} + previous={() => dispatchStep({ type: 'previous' })} + /> + + + {stepLabel === '장소 입력' && ( +
+ +
+ + + +
+
+ )} + {stepLabel === '정보 입력' && ( + <> + {inputLabel === '시작' && <>} + {inputLabel === '주소' && ( + <> + + + + )} + {inputLabel === '사진' && ( + <> + + + + )} + {inputLabel === '별점' && ( + <> + + + + )} + {inputLabel === '리뷰' && ( + <> + + + + )} + + )} + {stepLabel === '장소 확정' && } + {stepLabel === '완료' && } +
+ {alertOpened && ( + setAlertOpened(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/app/_components/add/common/AddConfirmContent.tsx b/frontend/src/app/_components/add/common/AddConfirmContent.tsx new file mode 100644 index 000000000..969d5068d --- /dev/null +++ b/frontend/src/app/_components/add/common/AddConfirmContent.tsx @@ -0,0 +1,48 @@ +'use client'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import React from 'react'; +import InfoTitle from './InfoTitle'; + +export default function AddConfirmContent() { + const url = usePathname(); + + const text = url.includes('spot') + ? '장소를 추가했어요!' + : '일정을 추가했어요!'; + + return ( + <> + +
+
+ 마이페이지에서 +
+
{text.slice(0, 3)} 확인하세요.
+
+
    + + 마이페이지로 바로가기 + + + 홈으로 바로가기 + +
+ + ); +} diff --git a/frontend/src/app/_components/add/common/AddPlaceInfo.tsx b/frontend/src/app/_components/add/common/AddPlaceInfo.tsx new file mode 100644 index 000000000..66def3422 --- /dev/null +++ b/frontend/src/app/_components/add/common/AddPlaceInfo.tsx @@ -0,0 +1,26 @@ +'use client'; + +import Image from 'next/image'; + +import { useContext } from 'react'; +import { AddSpotContext } from '../spot/SpotContext'; + +export default function AddPlaceInfo() { + const { name, address, spotMapImageUrl } = useContext(AddSpotContext); + + return ( +
+
+ 이름 + {name} +
+
+ 주소 + {address} +
+
+ {`${name} +
+
+ ); +} diff --git a/frontend/src/app/_components/add/common/AddSpotMap.tsx b/frontend/src/app/_components/add/common/AddSpotMap.tsx new file mode 100644 index 000000000..11e1db112 --- /dev/null +++ b/frontend/src/app/_components/add/common/AddSpotMap.tsx @@ -0,0 +1,110 @@ +'use client'; +import React, { + Dispatch, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { Marker, NaverMap, useNavermaps } from 'react-naver-maps'; +import { plusMarker } from '../../naver-map/markers/plusMarker'; + +import type { TAddSpotAction } from '../spot/SpotContext'; +import { getMap } from '../common/action'; +import { useGeolocation } from '../../naver-map/hooks/useGeolocation'; + +export default function AddSpotMap({ + place, + dispatchStep, + dispatchSpot, +}: { + place?: { + name: string; + roadAddress: string; + coords: { lat: number; lng: number }; + }; + dispatchStep: Dispatch<{ type: 'next' } | { type: 'previous' }>; + dispatchSpot: Dispatch; +}) { + const navermaps = useNavermaps(); + const markerRef = useRef(null); + const mapRef = useRef(null); + const [center, setCenter] = useState<{ lat: number; lng: number }>({ + lat: 37.5135869, + lng: 127.0621708, + }); + const [isGeolocationLoading, setIsGeolocationLoading] = useState(false); + + const { onSuccessGeolocation, onErrorGeolocation } = useGeolocation( + mapRef, + setCenter, + setIsGeolocationLoading, + ); + + useEffect(() => { + if (!mapRef.current) { + return; + } + + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + onSuccessGeolocation, + onErrorGeolocation, + ); + } else { + setCenter({ lat: 37.5135869, lng: 127.0621708 }); + } + }, [mapRef, onSuccessGeolocation, onErrorGeolocation]); + + useEffect(() => { + if (mapRef.current && place) { + const coord = { ...place.coords }; + mapRef.current.panTo(coord); + } + }, [place]); + + async function handleClick(place: { + name: string; + roadAddress: string; + coords: { lat: number; lng: number }; + }) { + const { name, roadAddress, coords } = place; + + dispatchSpot({ type: 'SET_NAME', payload: name }); + dispatchSpot({ type: 'SET_ADDRESS', payload: roadAddress }); + dispatchSpot({ type: 'SET_COORDS', payload: coords }); + + // Image URL | Base64 DataURL + const imageUrl = await getMap(name, roadAddress, coords); + + if (imageUrl.status === 'failed') { + console.log('지도 이미지를 얻지 못했습니다!'); + alert('지도 이미지를 얻지 못했습니다!'); + } else { + dispatchSpot({ type: 'SET_SPOT_MAP_URL', payload: imageUrl.data }); + dispatchStep({ type: 'next' }); + } + } + + return ( + + {place && ( + handleClick(place)} + > + )} + + ); +} diff --git a/frontend/src/app/_components/add/common/InfoTitle.tsx b/frontend/src/app/_components/add/common/InfoTitle.tsx new file mode 100644 index 000000000..a762fec1b --- /dev/null +++ b/frontend/src/app/_components/add/common/InfoTitle.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +interface TInfoTitle { + label: string; + additionalLabel: string; + textSizeAndLineHeight?: string; + fontBold?: string; + height?: string; +} + +export default function InfoTitle({ + label, + additionalLabel, + textSizeAndLineHeight, + fontBold, + height, +}: TInfoTitle) { + return ( +
+ + {label} + {additionalLabel.slice(0, 1)} + + {additionalLabel.slice(1)} +
+ ); +} diff --git a/frontend/src/app/_components/add/common/PostRating.tsx b/frontend/src/app/_components/add/common/PostRating.tsx new file mode 100644 index 000000000..a93cf1c32 --- /dev/null +++ b/frontend/src/app/_components/add/common/PostRating.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useContext, useState } from 'react'; +import { AddSpotContext } from '../spot/SpotContext'; + +import StarIcon from '/public/icons/star.svg'; + +import type { Dispatch } from 'react'; +import type { EventFor } from '@/types/type'; +import type { TAddSpotAction } from '../spot/SpotContext'; + +export default function PostRating({ + dispatch, +}: { + dispatch: Dispatch; +}) { + const addSpotState = useContext(AddSpotContext); + + const [hoverValue, setHoverValue] = useState(1); + + const onMouseEnter = (e: EventFor<'div', 'onKeyDown'>, value: number) => { + e.key === 'Enter' && dispatch({ type: 'SET_RATING', payload: value }); + }; + + const onClickStar = (value: number) => { + dispatch({ type: 'SET_RATING', payload: value }); + }; + + const onHoverStar = (value: number) => { + setHoverValue(value); + }; + + return ( +
+ {[...Array(5)].map((_, i) => ( +
onMouseEnter(e, i + 1)}> + +
+ ))} +
+ ); +} diff --git a/frontend/src/app/_components/add/common/PostReview.tsx b/frontend/src/app/_components/add/common/PostReview.tsx new file mode 100644 index 000000000..9d7d22960 --- /dev/null +++ b/frontend/src/app/_components/add/common/PostReview.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useContext } from 'react'; + +import { AddSpotContext } from '../spot/SpotContext'; + +import type { Dispatch } from 'react'; +import type { EventFor } from '@/types/type'; + +import type { TAddSpotAction } from '../spot/SpotContext'; + +interface TPostReviewProps { + viewTitle?: boolean; + dispatch: Dispatch; +} + +export default function PostReview({ viewTitle, dispatch }: TPostReviewProps) { + const { review } = useContext(AddSpotContext); + + const maxLength = 30; + + const onChangeReview = (e: EventFor<'input' | 'textarea', 'onChange'>) => { + const { name, value } = e.currentTarget; + if (name === 'review' && e.target.value.length > maxLength) { + e.target.blur(); + e.target.focus(); + + return; + } + + dispatch({ type: 'SET_REVIEW', payload: { ...review, [name]: value } }); + }; + + return ( +
+ {viewTitle && ( + + )} + +
+