From cbc8ede31772009d5d1ebd94bdc237a9e65f2bff Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 16 Jul 2024 10:59:20 +0900 Subject: [PATCH 01/31] =?UTF-8?q?feat:=20PR=20Open=EC=8B=9C=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20prefix=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/issue-branch.yml | 44 ++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/.github/issue-branch.yml b/.github/issue-branch.yml index ef54eb2f2..690e34794 100644 --- a/.github/issue-branch.yml +++ b/.github/issue-branch.yml @@ -1,6 +1,8 @@ branchName: '${issue.number}-${issue.body[-20]}' gitSafeReplacementChar: "" openDraftPR: true +conventionalPrTitles: true +conventionalStyle: semver-no-gitmoji autoCloseIssue: true branches: - label: @@ -13,22 +15,18 @@ branches: - frontend prefix: fe- name: fe/develop + - label: docs + skip: true + - label: refactor + skip: true + - label: chore + skip: true + - label: test + skip: true + - label: fix + skip: true - label: '*' skip: true - # - label: docs - # prefix: docs/ - # - label: hotfix - # prefix: hotfix/ - # - label: refactor - # prefix: refactor/ - # - label: chore - # prefix: chore/ - # - label: release - # prefix: release/ - # - label: test - # prefix: test/ - # - label: fix - # prefix: fix/ commentMessage: | 🚀 안녕하세요 @${assignee.login}님! 작업을 시작하셨군요? @@ -48,4 +46,20 @@ copyIssueLabelsToPR: true copyIssueAssigneeToPR: true copyIssueProjectsToPR: true copyIssueMilestoneToPR: true - + +conventionalLabels: + fix: + fix: '🔨' + feat: + feature: '✨' + chore: + chore: + docs: + docs: '📝' + style: + style: '💎' + refactor: + refactor: '♻️' + test: + test: '✅' + From 11222840da63a1479bbb375e8bde8e17cc269acd Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 16 Jul 2024 13:11:09 +0900 Subject: [PATCH 02/31] =?UTF-8?q?feat:=20AWS=20self-hosted=20runner=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=9D=B4=EC=9A=A9=20CD=20pipeline=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-be-dev-server.yml | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/cd-be-dev-server.yml diff --git a/.github/workflows/cd-be-dev-server.yml b/.github/workflows/cd-be-dev-server.yml new file mode 100644 index 000000000..138f239c0 --- /dev/null +++ b/.github/workflows/cd-be-dev-server.yml @@ -0,0 +1,63 @@ +name: Build and Deploy + +on: + push: + branches: be/main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Build project using Gradle + run: ./gradlew clean bootJar + + - name: Upload build artifact + uses: actions/upload-artifact@v3 + with: + name: cruru-be-develop-jar + path: build/libs/cruru-0.0.1-SNAPSHOT.jar + + deploy: + runs-on: self-hosted + needs: build + + steps: + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: cruru-be-develop-jar + path: ./build/libs + + - name: Check if room-esc server is running on port 8080 + id: check-server-on-port + run: | + echo "Checking if port 8080 is in use..." + PID=$(lsof -t -i:8080 || true) + if [ -n "$PID" ]; then + echo "server_running=true" >> $GITHUB_ENV + echo "PID=$PID" >> $GITHUB_ENV + else + echo "server_running=false" >> $GITHUB_ENV + fi + + - name: Stop server if running + if: env.server_running == 'true' + run: | + echo "Stopping server running on port 8080..." + kill -9 $PID + echo "Preivous running Server stopped." + + - name: Start server + run: | + nohup java -jar build/libs/cruru-0.0.1-SNAPSHOT.jar & + echo "Lastest Backend API Server started." From 940a3752b27cecfe5fa28be06364228628561ef9 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 16 Jul 2024 13:23:28 +0900 Subject: [PATCH 03/31] =?UTF-8?q?refactor:=20branch=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=8C=80=EC=83=81=20label=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/issue-branch.yml | 77 ++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/.github/issue-branch.yml b/.github/issue-branch.yml index 690e34794..5890912b3 100644 --- a/.github/issue-branch.yml +++ b/.github/issue-branch.yml @@ -1,8 +1,6 @@ branchName: '${issue.number}-${issue.body[-20]}' gitSafeReplacementChar: "" openDraftPR: true -conventionalPrTitles: true -conventionalStyle: semver-no-gitmoji autoCloseIssue: true branches: - label: @@ -15,16 +13,58 @@ branches: - frontend prefix: fe- name: fe/develop + - label: + - refactor + - backend + prefix: be- + name: be/develop + - label: + - refactor + - frontend + prefix: fe- + name: fe/develop + - label: + - chore + - backend + prefix: be- + name: be/develop + - label: + - chore + - frontend + prefix: fe- + name: fe/develop + - label: + - test + - backend + prefix: be- + name: be/develop + - label: + - test + - frontend + prefix: fe- + name: fe/develop + - label: + - fix + - backend + prefix: be- + name: be/develop + - label: + - fix + - frontend + prefix: fe- + name: fe/develop + - label: + - hotfix + - backend + prefix: be-hotfix- + name: be/release + - label: + - hotfix + - frontend + prefix: fe-hotfix- + name: fe/release - label: docs skip: true - - label: refactor - skip: true - - label: chore - skip: true - - label: test - skip: true - - label: fix - skip: true - label: '*' skip: true @@ -46,20 +86,3 @@ copyIssueLabelsToPR: true copyIssueAssigneeToPR: true copyIssueProjectsToPR: true copyIssueMilestoneToPR: true - -conventionalLabels: - fix: - fix: '🔨' - feat: - feature: '✨' - chore: - chore: - docs: - docs: '📝' - style: - style: '💎' - refactor: - refactor: '♻️' - test: - test: '✅' - From 848bbb31ba016ba7c9c41c7f45d664e7c334cfa8 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 16 Jul 2024 13:51:02 +0900 Subject: [PATCH 04/31] =?UTF-8?q?chore:=20=EC=88=98=EB=8F=99=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-be-dev-server.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cd-be-dev-server.yml b/.github/workflows/cd-be-dev-server.yml index 138f239c0..fa6722f13 100644 --- a/.github/workflows/cd-be-dev-server.yml +++ b/.github/workflows/cd-be-dev-server.yml @@ -1,6 +1,7 @@ name: Build and Deploy on: + workflow_dispatch: push: branches: be/main From bdf469f1d285278f3d2497990eebf15757dbd52e Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Wed, 17 Jul 2024 13:54:08 +0900 Subject: [PATCH 05/31] =?UTF-8?q?fea(slack-alert):=20PR=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=99=84=EB=A3=8C,=20Approve=20=EC=8A=AC=EB=9E=99?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-event-slack-alert.yml | 123 ++++++++++++++++++ .../review-request-slack-mention.yml | 49 ------- 2 files changed, 123 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/pr-event-slack-alert.yml delete mode 100644 .github/workflows/review-request-slack-mention.yml diff --git a/.github/workflows/pr-event-slack-alert.yml b/.github/workflows/pr-event-slack-alert.yml new file mode 100644 index 000000000..a588f4e88 --- /dev/null +++ b/.github/workflows/pr-event-slack-alert.yml @@ -0,0 +1,123 @@ +name: PR Review - req, submitted, approve Slack alert + +on: + pull_request: + types: [review_requested] + branches: + - main + - 'be/**' + - 'fe/**' + pull_request_review: + types: [submitted] + branches: + - main + - 'be/**' + - 'fe/**' + +env: + Dobby-Kim: "U07BJABU6G1" + U07BJABU6G1: "BE팀 도비" + Chocochip101: "U07BUEJDS8G" + U07BUEJDS8G: "BE팀 초코칩" + xogns1514: "U07AZ26UC2J" + U07AZ26UC2J: "BE팀 러쉬" + lurgi: "U07BJB1M53K" + U07BJB1M53K: "FE팀 러기" + llqqssttyy: "U07AZ2992CW" + U07AZ2992CW: "FE팀 렛서" + cutehumanS2: "U07B88ZQDU4" + U07B88ZQDU4: "BE팀 냥인" + HyungHoKim00: "U07B5HBKZM1" + U07B5HBKZM1: "BE팀 명오" + seongjinme: "U07B9HQDF4M" + U07B9HQDF4M: "FE팀 아르" + +jobs: + review-requested_requested: + if: github.event.action == 'review_requested' + runs-on: ubuntu-latest + steps: + - name: Set reviewer and sender variables + id: set-vars + run: | + echo "REVIEWER_SLACK_ID=${{ env[github.event.requested_reviewer.login] }}" >> $GITHUB_ENV + echo "SENDER_SLACK_ID=${{ env[github.event.sender.login] }}" >> $GITHUB_ENV + + - name: pr reviewer 되면 slack 알림 보냄 + uses: slackapi/slack-github-action@v1.24.0 + with: + channel-id: ${{ secrets.REVIEW_MENTION_CHANNEL_ID }} + payload: | + { + "text": "pr review request", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": " --- \n ✨리뷰 요청✨ \n <@${{ env.REVIEWER_SLACK_ID }}> 님! \n 🚀 <@${{ env.SENDER_SLACK_ID }}>님에게서 **${{ github.event.pull_request.title }}**에 대한 리뷰 요청이 왔습니다! \n :muscle: 바쁘시겠지만 아래의 링크에서 확인주세요 \n \n ⚡️ <${{ github.event.pull_request.html_url }}|PR 바로가기 링크>" + } + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + + review-submitted_alert: + if: github.event.action == 'submitted' && github.event.review.state != 'APPROVED' + runs-on: ubuntu-latest + steps: + - name: Set reviewer and reviewee variables + id: set-vars + run: | + echo "REVIEWER_SLACK_ID=${{ env[github.event.sender.login] }}" >> $GITHUB_ENV + echo "ASSIGNEE_SLACK_ID=${{ env[github.event.pull_request.login] }}" >> $GITHUB_ENV + + - name: pr 리뷰 요청시 reviewer에게 slack 알림 발송 + uses: slackapi/slack-github-action@v1.24.0 + with: + channel-id: ${{ secrets.REVIEW_MENTION_CHANNEL_ID }} + payload: | + { + "text": "pr review request", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": " --- \n 🔥리뷰 완료🔥 \n <@${{ env.ASSIGNEE_SLACK_ID }}> 님! \n 🚀 <@${{ env.REVIEWER_SLACK_ID }}>님에게서 **${{ github.event.pull_request.title }}**에 대한 리뷰를 남기셨어요! \n ✨ 아래의 링크에서 확인주세요 :muscle::muscle: \n \n ⚡️ <${{ github.event.pull_request.html_url }}|PR 바로가기 링크>" + } + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + + pr-approved_alert: + if: github.event.review.state == 'APPROVED' + runs-on: ubuntu-latest + steps: + - name: Set assignee variables + id: set-vars + run: | + echo "ASSIGNEE_SLACK_ID=${{ env[github.event.pull_request.login] }}" >> $GITHUB_ENV + + - name: pr reviewer 되면 slack 알림 보냄 + uses: slackapi/slack-github-action@v1.24.0 + with: + channel-id: ${{ secrets.TASK_COMPLETE_SLACK_CHANNEL_ID }} + payload: | + { + "text": "pr review request", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": " --- \n 🏁PR 승인🏁 \n <@${{ env.ASSIGNEE_SLACK_ID }}> 님! \n 🚀 작업하신 **${{ github.event.pull_request.title }}** 가 모두 Approve 됐어요! \n :muscle: 아래의 링크에서 Merge를 진행해주세요 \n \n ⚡️ <${{ github.event.pull_request.html_url }}|PR 바로가기 링크>" + } + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/review-request-slack-mention.yml b/.github/workflows/review-request-slack-mention.yml deleted file mode 100644 index d5ff06fba..000000000 --- a/.github/workflows/review-request-slack-mention.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: send slack mention message to requested reviewer - -on: - pull_request: - types: [review_requested] - branches: - - main - - 'be/**' - - 'fe/**' - -env: - Dobby-Kim: "U07BJABU6G1" - Chocochip101: "U07BUEJDS8G" - xogns1514: "U07AZ26UC2J" - lurgi: "U07BJB1M53K" - llqqssttyy: "U07AZ2992CW" - cutehumanS2: "U07B88ZQDU4" - HyungHoKim00: "U07B5HBKZM1" - seongjinme: "U07B9HQDF4M" - -jobs: - specific_review_requested: - runs-on: ubuntu-latest - steps: - - name: Set reviewer and sender variables - id: set-vars - run: | - echo "REVIEWER_SLACK_ID=${{ env[github.event.requested_reviewer.login] }}" >> $GITHUB_ENV - echo "SENDER_SLACK_ID=${{ env[github.event.sender.login] }}" >> $GITHUB_ENV - - - name: pr reviewer 되면 slack 알림 보냄 - uses: slackapi/slack-github-action@v1.24.0 - with: - channel-id: ${{ secrets.REVIEW_MENTION_CHANNEL_ID }} - payload: | - { - "text": "pr review request", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "<@${{ env.REVIEWER_SLACK_ID }}> 님! \n 🚀 <@${{ env.SENDER_SLACK_ID }}>님에게서 **${{ github.event.pull_request.title }}**에 대한 리뷰 요청이 왔습니다! \n ✨ 바쁘시겠지만 아래의 링크에서 확인주세요 :muscle::muscle: \n \n ⚡️ <${{ github.event.pull_request.html_url }}|PR 바로가기 링크>" - } - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} From 3c05b4d38fde5e3d60d5e88f001a8200da461901 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Wed, 17 Jul 2024 14:40:31 +0900 Subject: [PATCH 06/31] =?UTF-8?q?chore(issue-automation):=20issue=20assign?= =?UTF-8?q?ee=20payload=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index 3ce39b118..7c330268b 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -48,7 +48,7 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "🚀 <@${{ env.ASSIGNEE_SLACK_ID }}> 님이 \n <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}> 작업을 시작했습니다! :muscle::muscle:" + "text": "--- \n 🚀 작업 시작 🚀 \n <@${{ env.ASSIGNEE_SLACK_ID }}> 님이 \n <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}> 작업을 시작했습니다!:muscle:" } } ] @@ -64,7 +64,7 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=${{ env[github.event.assignee.login] }}" >> $GITHUB_ENV + echo "ASSIGNEE_SLACK_ID=${{ env[github.event.issue.assignee.login] }}" >> $GITHUB_ENV - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 @@ -78,7 +78,7 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": ":pushpin: <@${{ env.ASSIGNEE_SLACK_ID }}> 님의 할당 이슈인 \n\n :confetti_ball::confetti_ball: <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}> 작업이 완료(종료)됐습니다! :confetti_ball::confetti_ball:" + "text": "--- \n :pushpin: 작업 완료 :pushpin: \n <@${{ env.ASSIGNEE_SLACK_ID }}> 님의 할당 이슈인 \n\n :confetti_ball::confetti_ball: <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}> 작업이 완료(종료)됐습니다! :confetti_ball::confetti_ball:" } } ] From 443600c443f27adcfd651e1d97357a96110f5de6 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Wed, 17 Jul 2024 16:39:59 +0900 Subject: [PATCH 07/31] =?UTF-8?q?chore(actions-test):=20Jacoco=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20PR=20=EB=A9=94=EC=84=B8=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-code-coverage-test.yml | 56 +++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/pr-code-coverage-test.yml diff --git a/.github/workflows/pr-code-coverage-test.yml b/.github/workflows/pr-code-coverage-test.yml new file mode 100644 index 000000000..aaa285ba9 --- /dev/null +++ b/.github/workflows/pr-code-coverage-test.yml @@ -0,0 +1,56 @@ +name: PR open시 테스트 커버리지 검증 + +on: + pull_request: + types: [opened] + branches: + - be/develop + +jobs: + test-coverage-pr-opened: + if: startsWith(github.head_ref, 'be-') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + + - name: Cache Gradle wrapper + uses: actions/cache@v3 + with: + path: | + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-wrapper- + + - name: Cache Gradle dependencies + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', '**/gradle-wrapper.properties', '**/settings.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: gradlew 권한 할당 + run: chmod +x gradlew + + - name: Run tests and generate coverage report + run: ./gradlew test + + - name: 테스트 커버리지를 PR에 코멘트로 등록 + uses: madrapps/jacoco-report@v1.6.1 + with: + title: 📌 Test Coverage Report + paths: ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 80 + min-coverage-changed-files: 80 From afec479b52d8419581efd92f22a4b4a11e1d769d Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Wed, 17 Jul 2024 16:53:30 +0900 Subject: [PATCH 08/31] =?UTF-8?q?chore(actions-test):=20Jacoco=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20PR=20=EB=A9=94=EC=84=B8=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-code-coverage-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-code-coverage-test.yml b/.github/workflows/pr-code-coverage-test.yml index aaa285ba9..3f3b23f89 100644 --- a/.github/workflows/pr-code-coverage-test.yml +++ b/.github/workflows/pr-code-coverage-test.yml @@ -2,7 +2,7 @@ name: PR open시 테스트 커버리지 검증 on: pull_request: - types: [opened] + types: [opened, ready_for_review] branches: - be/develop From 90b47a914ae459c119cd805b4fe24b13f86520c6 Mon Sep 17 00:00:00 2001 From: Kwoun Ki Ho <73146678+Chocochip101@users.noreply.github.com> Date: Fri, 19 Jul 2024 13:39:46 +0900 Subject: [PATCH 09/31] =?UTF-8?q?docs:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=80=EC=9B=90=20=EC=86=8C=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 37a21a6d3..0f041c577 100644 --- a/README.md +++ b/README.md @@ -1 +1,20 @@ -# 2024-cruru \ No newline at end of file +# 서비스 이름 + +### 크루루 (cruru) + +# 크루루는 이런 서비스에요 + +## 주제 + +복잡한 리크루팅 과정을 간소화하는 맞춤형 리크루팅 관리 솔루션 + +## 설명 + +서비스 ‘크루루’는 대학생 연합 동아리를 위한 ATS(지원자 추적 시스템)입니다. 모집 공고 관리, 지원자 목록 관리, 지원 항목 커스터마이징 등을 제공합니다. 해당 서비스를 통해 소규모 리크루팅 프로세스를 효율적으로 관리할 수 있습니다. + +# 💻 개발자 + +| ![아르](https://github.com/user-attachments/assets/2f63c5ab-43bb-417b-92bf-73fd761208a9) | ![러기](https://github.com/user-attachments/assets/f2c8ff64-1a83-466c-851a-ab14cd5530bc)| ![렛서](https://github.com/user-attachments/assets/ff5d9e17-16d6-42fc-8754-c65554313e4e) | ![냥인](https://github.com/user-attachments/assets/4b20cc25-7104-413c-b89e-f22c34a8d0c9) | ![러쉬](https://github.com/user-attachments/assets/86225998-321c-4a11-9c30-2abff1b1c3a1) | ![명오](https://github.com/user-attachments/assets/5316b64b-bc98-446b-b55f-8fa014dbceaa) | ![도비](https://github.com/user-attachments/assets/777f53ac-07cf-43e3-8ebb-f11ae1dc8520) | ![초코칩](https://github.com/user-attachments/assets/dcbd7b64-0ee9-434e-936e-98bf4a36a03d) | +|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:| +| **FE** | **FE** | **FE** | **BE** | **BE** | **BE** | **BE** | **BE** | +|[아르](https://github.com/seongjinme)| [러기](https://github.com/lurgi) | [렛서](https://github.com/llqqssttyy) | [냥인](https://github.com/cutehumanS2) | [러쉬](https://github.com/xogns1514) | [명오](https://github.com/HyungHoKim00) | [도비](https://github.com/Dobby-Kim) | [초코칩](https://github.com/Chocochip101) | From c973228007ee166d7e58eb0effec357338de2d6d Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Fri, 19 Jul 2024 17:21:08 +0900 Subject: [PATCH 10/31] =?UTF-8?q?chore(CI):=20CI=20=EA=B3=BC=EC=A0=95=20na?= =?UTF-8?q?me=20=EB=B0=8F=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...r-code-coverage-test.yml => be-ci-pr-code-coverage-test.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{pr-code-coverage-test.yml => be-ci-pr-code-coverage-test.yml} (97%) diff --git a/.github/workflows/pr-code-coverage-test.yml b/.github/workflows/be-ci-pr-code-coverage-test.yml similarity index 97% rename from .github/workflows/pr-code-coverage-test.yml rename to .github/workflows/be-ci-pr-code-coverage-test.yml index 3f3b23f89..3c7e1a28d 100644 --- a/.github/workflows/pr-code-coverage-test.yml +++ b/.github/workflows/be-ci-pr-code-coverage-test.yml @@ -1,4 +1,4 @@ -name: PR open시 테스트 커버리지 검증 +name: BE CI - Test Coverage 검증 on: pull_request: From 258b305d59def657b22b660438d42ce096c83e9a Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Wed, 24 Jul 2024 15:14:43 +0900 Subject: [PATCH 11/31] =?UTF-8?q?=08chore(issue-auto):=20slack=20=EB=B0=9C?= =?UTF-8?q?=EC=8B=A0=20=EB=A9=94=EC=84=B8=EC=A7=80=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20name=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index 7c330268b..737c4c554 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -1,4 +1,4 @@ -name: issue - branch Create Automation +name: ALL/PM - Issue 자동 관리 on: issues: @@ -26,7 +26,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - notify-start-issue: + notify-open-issue: name: "이슈 작업 시작 -> Slack 체널 알림" runs-on: ubuntu-latest if: github.event.action == 'assigned' @@ -34,7 +34,7 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=${{ env[github.event.assignee.login] }}" >> $GITHUB_ENV + echo "ASSIGNEE_SLACK_ID=${{ env[github.event.issue.assignee.login] }}" >> $GITHUB_ENV - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 @@ -48,7 +48,7 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "--- \n 🚀 작업 시작 🚀 \n <@${{ env.ASSIGNEE_SLACK_ID }}> 님이 \n <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}> 작업을 시작했습니다!:muscle:" + "text": "〰️〰〰️〰 \n🚀 작업 시작 \n <@${{ env.ASSIGNEE_SLACK_ID }}> 님이 \n <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}> 작업을 시작했습니다!:muscle:" } } ] @@ -78,11 +78,10 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "--- \n :pushpin: 작업 완료 :pushpin: \n <@${{ env.ASSIGNEE_SLACK_ID }}> 님의 할당 이슈인 \n\n :confetti_ball::confetti_ball: <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}> 작업이 완료(종료)됐습니다! :confetti_ball::confetti_ball:" + "text": "〰️〰〰️〰 \n:pushpin:작업 완료\n <@${{ env.ASSIGNEE_SLACK_ID }}> 님의 할당 이슈인 \n\n:confetti_ball: <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}> 작업이 완료(종료)됐습니다! :confetti_ball:" } } ] } env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - From 5317c5927cbddb1b45a6ff7b8603fcbcc46ac701 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Wed, 24 Jul 2024 15:16:49 +0900 Subject: [PATCH 12/31] =?UTF-8?q?chore(Actions-CD):=20backend=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20=ED=95=B4=EB=8B=B9=EB=90=98=EB=8A=94=20CD=20workflo?= =?UTF-8?q?w=20main=20branch=EC=97=90=EC=84=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-be-dev-server.yml | 64 -------------------------- 1 file changed, 64 deletions(-) delete mode 100644 .github/workflows/cd-be-dev-server.yml diff --git a/.github/workflows/cd-be-dev-server.yml b/.github/workflows/cd-be-dev-server.yml deleted file mode 100644 index fa6722f13..000000000 --- a/.github/workflows/cd-be-dev-server.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Build and Deploy - -on: - workflow_dispatch: - push: - branches: be/main - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - - name: Build project using Gradle - run: ./gradlew clean bootJar - - - name: Upload build artifact - uses: actions/upload-artifact@v3 - with: - name: cruru-be-develop-jar - path: build/libs/cruru-0.0.1-SNAPSHOT.jar - - deploy: - runs-on: self-hosted - needs: build - - steps: - - name: Download build artifact - uses: actions/download-artifact@v3 - with: - name: cruru-be-develop-jar - path: ./build/libs - - - name: Check if room-esc server is running on port 8080 - id: check-server-on-port - run: | - echo "Checking if port 8080 is in use..." - PID=$(lsof -t -i:8080 || true) - if [ -n "$PID" ]; then - echo "server_running=true" >> $GITHUB_ENV - echo "PID=$PID" >> $GITHUB_ENV - else - echo "server_running=false" >> $GITHUB_ENV - fi - - - name: Stop server if running - if: env.server_running == 'true' - run: | - echo "Stopping server running on port 8080..." - kill -9 $PID - echo "Preivous running Server stopped." - - - name: Start server - run: | - nohup java -jar build/libs/cruru-0.0.1-SNAPSHOT.jar & - echo "Lastest Backend API Server started." From f74ef92db06438b42dbd24dfcd7c263e51761795 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Fri, 26 Jul 2024 12:35:22 +0900 Subject: [PATCH 13/31] =?UTF-8?q?chore(Actions-issue):=20issue=20-=20pr=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EA=B8=B0=EB=8A=A5=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/issue-branch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/issue-branch.yml b/.github/issue-branch.yml index 5890912b3..b9fa338d6 100644 --- a/.github/issue-branch.yml +++ b/.github/issue-branch.yml @@ -1,6 +1,7 @@ branchName: '${issue.number}-${issue.body[-20]}' gitSafeReplacementChar: "" openDraftPR: true +autoLinkIssue: true autoCloseIssue: true branches: - label: @@ -80,7 +81,6 @@ commentMessage: | 작업이 완료된 후, 리뷰어를 선택하고 Draft PR 내부의 `Ready for review` 버튼을 눌러주시면 됩니다! :) 제가 Slack으로 리뷰어에게 DM으로 알려드릴게요 - copyIssueDescriptionToPR: true copyIssueLabelsToPR: true copyIssueAssigneeToPR: true From 0f89b509eb0e331bec43aa0d66365e7bb628a4a9 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:38:12 +0900 Subject: [PATCH 14/31] =?UTF-8?q?chore(issue-automation):=20Slack=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20env=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index 737c4c554..4c2f84825 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -4,16 +4,6 @@ on: issues: types: [opened, assigned, closed] -env: - Dobby-Kim: "U07BJABU6G1" - Chocochip101: "U07BUEJDS8G" - xogns1514: "U07AZ26UC2J" - lurgi: "U07BJB1M53K" - llqqssttyy: "U07AZ2992CW" - cutehumanS2: "U07B88ZQDU4" - HyungHoKim00: "U07B5HBKZM1" - seongjinme: "U07B9HQDF4M" - jobs: create-issue-branch: name: "feature 이슈 할당 -> 브랜치-PR 자동 생성" @@ -34,7 +24,7 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=${{ env[github.event.issue.assignee.login] }}" >> $GITHUB_ENV + echo "ASSIGNEE_SLACK_ID=${{ github.event.issue.assignee.login,, }}" >> $GITHUB_ENV - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 @@ -64,7 +54,7 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=${{ env[github.event.issue.assignee.login] }}" >> $GITHUB_ENV + echo "ASSIGNEE_SLACK_ID=${{ github.event.issue.assignee.login,, }}" >> $GITHUB_ENV - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 From 77c6044aaa81aa5405b5812627aeb93faa0aa71f Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:40:27 +0900 Subject: [PATCH 15/31] =?UTF-8?q?chore(issue-automation):=20Slack=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20env=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=AC=B8=EB=B2=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index 4c2f84825..be3601a89 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -24,7 +24,7 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=${{ github.event.issue.assignee.login,, }}" >> $GITHUB_ENV + echo "ASSIGNEE_SLACK_ID=${ github.event.issue.assignee.login,, }" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 @@ -54,7 +54,7 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=${{ github.event.issue.assignee.login,, }}" >> $GITHUB_ENV + echo "ASSIGNEE_SLACK_ID=${ github.event.issue.assignee.login,, }" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 From 01c58f6cc630c63af221c447fb62390349e3ce57 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:44:58 +0900 Subject: [PATCH 16/31] =?UTF-8?q?chore(issue-automation):=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20lowercase=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index be3601a89..dc87520e1 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -24,8 +24,7 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=${ github.event.issue.assignee.login,, }" >> ${GITHUB_ENV} - + echo "ASSIGNEE_SLACK_ID=$(echo ${GITHUB_EVENT.issue.assignee.login} | tr '[:upper:]' '[:lower:]')" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 with: @@ -45,7 +44,7 @@ jobs: } env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - + notify-close-issue: name: "이슈 작업 종료 -> Slack 체널 알림" runs-on: ubuntu-latest @@ -54,8 +53,7 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=${ github.event.issue.assignee.login,, }" >> ${GITHUB_ENV} - + echo "ASSIGNEE_SLACK_ID=$(echo ${GITHUB_EVENT.issue.assignee.login} | tr '[:upper:]' '[:lower:]')" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 with: From 182927365b70a3a28daea394d5054ca3bd9dd4f4 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:53:46 +0900 Subject: [PATCH 17/31] =?UTF-8?q?chore(issue-automation):=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20ID=20lowercase=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index dc87520e1..2927406e9 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -24,7 +24,9 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=$(echo ${GITHUB_EVENT.issue.assignee.login} | tr '[:upper:]' '[:lower:]')" >> ${GITHUB_ENV} + ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} + ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN,,} + echo "ASSIGNEE_SLACK_ID=$ASSIGNEE_SLACK_ID" >> $GITHUB_ENV - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 with: @@ -53,7 +55,10 @@ jobs: - name: Set assignee variables id: set-vars run: | - echo "ASSIGNEE_SLACK_ID=$(echo ${GITHUB_EVENT.issue.assignee.login} | tr '[:upper:]' '[:lower:]')" >> ${GITHUB_ENV} + ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} + ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN,,} + echo "ASSIGNEE_SLACK_ID=$ASSIGNEE_SLACK_ID" >> $GITHUB_ENV + - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 with: From c26ef4648ad1557b676086782d39687bc1fc313b Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:57:43 +0900 Subject: [PATCH 18/31] =?UTF-8?q?chore(issue-automation):=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20ID=20lowercase=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index 2927406e9..8bdf52563 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -25,8 +25,7 @@ jobs: id: set-vars run: | ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} - ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN,,} - echo "ASSIGNEE_SLACK_ID=$ASSIGNEE_SLACK_ID" >> $GITHUB_ENV + echo "ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN,,}" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 with: @@ -56,8 +55,7 @@ jobs: id: set-vars run: | ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} - ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN,,} - echo "ASSIGNEE_SLACK_ID=$ASSIGNEE_SLACK_ID" >> $GITHUB_ENV + echo "ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN,,}" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 From 4c4b7842379c541735fd6802f48a6cb9005e599a Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:59:24 +0900 Subject: [PATCH 19/31] =?UTF-8?q?chore(issue-automation):=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20lowercase=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EB=AC=B8=EB=B2=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index 8bdf52563..d607784d1 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -25,7 +25,7 @@ jobs: id: set-vars run: | ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} - echo "ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN,,}" >> ${GITHUB_ENV} + echo "ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 with: @@ -55,7 +55,7 @@ jobs: id: set-vars run: | ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} - echo "ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN,,}" >> ${GITHUB_ENV} + echo "ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 From abc1623c1499554645eea7a3689cd9a3e4133952 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:00:28 +0900 Subject: [PATCH 20/31] =?UTF-8?q?chore(issue-automation):=20Slack=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20lowercase=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=AC=B8=EB=B2=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index d607784d1..dc953afdd 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -25,7 +25,7 @@ jobs: id: set-vars run: | ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} - echo "ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} + echo "ASSIGNEE_SLACK_ID=${ASSIGNEE_LOGIN@ㅣ}" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 with: @@ -55,7 +55,7 @@ jobs: id: set-vars run: | ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} - echo "ASSIGNEE_SLACK_ID=${$ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} + echo "ASSIGNEE_SLACK_ID=${ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 From 41fbb2a946b3d52e9dd7a8506f45ce1e10c52e21 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:01:40 +0900 Subject: [PATCH 21/31] =?UTF-8?q?chore(issue-automation):=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index dc953afdd..133f4c4a2 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -25,7 +25,7 @@ jobs: id: set-vars run: | ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} - echo "ASSIGNEE_SLACK_ID=${ASSIGNEE_LOGIN@ㅣ}" >> ${GITHUB_ENV} + echo "ASSIGNEE_SLACK_ID=${ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 uses: slackapi/slack-github-action@v1.24.0 with: From b8f804c4cbc822ad052b35c3b80898cae99ac1bf Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:04:11 +0900 Subject: [PATCH 22/31] =?UTF-8?q?chore(slack):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20ID=20=EC=A0=95=EB=B3=B4=20JSON=20=EB=B3=B4=EC=95=88?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/reviewers.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .github/workflows/reviewers.json diff --git a/.github/workflows/reviewers.json b/.github/workflows/reviewers.json deleted file mode 100644 index 8d6892851..000000000 --- a/.github/workflows/reviewers.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Dobby-Kim": "U07BJABU6G1", - "Chocochip101": "U07BUEJDS8G", - "xogns1514": "U07AZ26UC2J", - "lurgi": "U07BJB1M53K", - "llqqssttyy": "U07AZ2992CW", - "cutehumanS2": "U07B88ZQDU4", - "HyungHoKim00": "U07B5HBKZM1", - "seongjinme": "U07B9HQDF4M" -} - From 8ba13ce27d72afe762b30c556991de83c8505342 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:06:12 +0900 Subject: [PATCH 23/31] =?UTF-8?q?chore(slack-alert):=20main=20branch?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=20Actions=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-event-slack-alert.yml | 123 --------------------- 1 file changed, 123 deletions(-) delete mode 100644 .github/workflows/pr-event-slack-alert.yml diff --git a/.github/workflows/pr-event-slack-alert.yml b/.github/workflows/pr-event-slack-alert.yml deleted file mode 100644 index a588f4e88..000000000 --- a/.github/workflows/pr-event-slack-alert.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: PR Review - req, submitted, approve Slack alert - -on: - pull_request: - types: [review_requested] - branches: - - main - - 'be/**' - - 'fe/**' - pull_request_review: - types: [submitted] - branches: - - main - - 'be/**' - - 'fe/**' - -env: - Dobby-Kim: "U07BJABU6G1" - U07BJABU6G1: "BE팀 도비" - Chocochip101: "U07BUEJDS8G" - U07BUEJDS8G: "BE팀 초코칩" - xogns1514: "U07AZ26UC2J" - U07AZ26UC2J: "BE팀 러쉬" - lurgi: "U07BJB1M53K" - U07BJB1M53K: "FE팀 러기" - llqqssttyy: "U07AZ2992CW" - U07AZ2992CW: "FE팀 렛서" - cutehumanS2: "U07B88ZQDU4" - U07B88ZQDU4: "BE팀 냥인" - HyungHoKim00: "U07B5HBKZM1" - U07B5HBKZM1: "BE팀 명오" - seongjinme: "U07B9HQDF4M" - U07B9HQDF4M: "FE팀 아르" - -jobs: - review-requested_requested: - if: github.event.action == 'review_requested' - runs-on: ubuntu-latest - steps: - - name: Set reviewer and sender variables - id: set-vars - run: | - echo "REVIEWER_SLACK_ID=${{ env[github.event.requested_reviewer.login] }}" >> $GITHUB_ENV - echo "SENDER_SLACK_ID=${{ env[github.event.sender.login] }}" >> $GITHUB_ENV - - - name: pr reviewer 되면 slack 알림 보냄 - uses: slackapi/slack-github-action@v1.24.0 - with: - channel-id: ${{ secrets.REVIEW_MENTION_CHANNEL_ID }} - payload: | - { - "text": "pr review request", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": " --- \n ✨리뷰 요청✨ \n <@${{ env.REVIEWER_SLACK_ID }}> 님! \n 🚀 <@${{ env.SENDER_SLACK_ID }}>님에게서 **${{ github.event.pull_request.title }}**에 대한 리뷰 요청이 왔습니다! \n :muscle: 바쁘시겠지만 아래의 링크에서 확인주세요 \n \n ⚡️ <${{ github.event.pull_request.html_url }}|PR 바로가기 링크>" - } - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - - review-submitted_alert: - if: github.event.action == 'submitted' && github.event.review.state != 'APPROVED' - runs-on: ubuntu-latest - steps: - - name: Set reviewer and reviewee variables - id: set-vars - run: | - echo "REVIEWER_SLACK_ID=${{ env[github.event.sender.login] }}" >> $GITHUB_ENV - echo "ASSIGNEE_SLACK_ID=${{ env[github.event.pull_request.login] }}" >> $GITHUB_ENV - - - name: pr 리뷰 요청시 reviewer에게 slack 알림 발송 - uses: slackapi/slack-github-action@v1.24.0 - with: - channel-id: ${{ secrets.REVIEW_MENTION_CHANNEL_ID }} - payload: | - { - "text": "pr review request", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": " --- \n 🔥리뷰 완료🔥 \n <@${{ env.ASSIGNEE_SLACK_ID }}> 님! \n 🚀 <@${{ env.REVIEWER_SLACK_ID }}>님에게서 **${{ github.event.pull_request.title }}**에 대한 리뷰를 남기셨어요! \n ✨ 아래의 링크에서 확인주세요 :muscle::muscle: \n \n ⚡️ <${{ github.event.pull_request.html_url }}|PR 바로가기 링크>" - } - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - - pr-approved_alert: - if: github.event.review.state == 'APPROVED' - runs-on: ubuntu-latest - steps: - - name: Set assignee variables - id: set-vars - run: | - echo "ASSIGNEE_SLACK_ID=${{ env[github.event.pull_request.login] }}" >> $GITHUB_ENV - - - name: pr reviewer 되면 slack 알림 보냄 - uses: slackapi/slack-github-action@v1.24.0 - with: - channel-id: ${{ secrets.TASK_COMPLETE_SLACK_CHANNEL_ID }} - payload: | - { - "text": "pr review request", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": " --- \n 🏁PR 승인🏁 \n <@${{ env.ASSIGNEE_SLACK_ID }}> 님! \n 🚀 작업하신 **${{ github.event.pull_request.title }}** 가 모두 Approve 됐어요! \n :muscle: 아래의 링크에서 Merge를 진행해주세요 \n \n ⚡️ <${{ github.event.pull_request.html_url }}|PR 바로가기 링크>" - } - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} From 4c6eabd61d369366dd7dd07ad3bb9cbe0f9389a3 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:07:12 +0900 Subject: [PATCH 24/31] =?UTF-8?q?chore(actions-test):=20main=20branch=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20Actions=20workflow=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/be-ci-pr-code-coverage-test.yml | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 .github/workflows/be-ci-pr-code-coverage-test.yml diff --git a/.github/workflows/be-ci-pr-code-coverage-test.yml b/.github/workflows/be-ci-pr-code-coverage-test.yml deleted file mode 100644 index 3c7e1a28d..000000000 --- a/.github/workflows/be-ci-pr-code-coverage-test.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: BE CI - Test Coverage 검증 - -on: - pull_request: - types: [opened, ready_for_review] - branches: - - be/develop - -jobs: - test-coverage-pr-opened: - if: startsWith(github.head_ref, 'be-') - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: 17 - - - name: Cache Gradle wrapper - uses: actions/cache@v3 - with: - path: | - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle-wrapper- - - - name: Cache Gradle dependencies - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', '**/gradle-wrapper.properties', '**/settings.gradle*') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: gradlew 권한 할당 - run: chmod +x gradlew - - - name: Run tests and generate coverage report - run: ./gradlew test - - - name: 테스트 커버리지를 PR에 코멘트로 등록 - uses: madrapps/jacoco-report@v1.6.1 - with: - title: 📌 Test Coverage Report - paths: ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml - token: ${{ secrets.GITHUB_TOKEN }} - min-coverage-overall: 80 - min-coverage-changed-files: 80 From 816d296dce0a624a48c7d9f047b7219d171a6cb8 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Wed, 31 Jul 2024 12:22:47 +0900 Subject: [PATCH 25/31] =?UTF-8?q?chore(Actions-issue):=20=EB=A9=94?= =?UTF-8?q?=EC=84=B8=EC=A7=80=20payload=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20event=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index 133f4c4a2..0327d8998 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -2,7 +2,7 @@ name: ALL/PM - Issue 자동 관리 on: issues: - types: [opened, assigned, closed] + types: [assigned, closed] jobs: create-issue-branch: @@ -32,7 +32,7 @@ jobs: channel-id: ${{ secrets.IN_PROGRESS_SLACK_CHANNEL_ID }} payload: | { - "text": "pr review request", + "text": "🔔 작업 시작 알림 🔔", "blocks": [ { "type": "section", @@ -63,7 +63,7 @@ jobs: channel-id: ${{ secrets.TASK_COMPLETE_SLACK_CHANNEL_ID }} payload: | { - "text": "pr review request", + "text": "🎉 작업 완료 알림 🎉", "blocks": [ { "type": "section", From 37291eb08c0a3e4054a3f5e256ef3b4cc24501e3 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Mon, 5 Aug 2024 13:21:35 +0900 Subject: [PATCH 26/31] =?UTF-8?q?chore(Actions-issue):=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20uses=20=EB=B2=84=EC=A0=84=20=EC=B5=9C=EC=8B=A0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-branch-pr-automation.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-branch-pr-automation.yml b/.github/workflows/issue-branch-pr-automation.yml index 0327d8998..0b9ab9379 100644 --- a/.github/workflows/issue-branch-pr-automation.yml +++ b/.github/workflows/issue-branch-pr-automation.yml @@ -11,7 +11,7 @@ jobs: if: github.event.action == 'assigned' steps: - name: create the issue branch - uses: robvanderleek/create-issue-branch@main + uses: robvanderleek/create-issue-branch@1.7.0 id: create-issue-branch env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -27,7 +27,7 @@ jobs: ASSIGNEE_LOGIN=${{ github.event.issue.assignee.login }} echo "ASSIGNEE_SLACK_ID=${ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 - uses: slackapi/slack-github-action@v1.24.0 + uses: slackapi/slack-github-action@v1.26.0 with: channel-id: ${{ secrets.IN_PROGRESS_SLACK_CHANNEL_ID }} payload: | @@ -58,7 +58,7 @@ jobs: echo "ASSIGNEE_SLACK_ID=${ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} - name: 작업 시작 -> Slack 체널 알림 - uses: slackapi/slack-github-action@v1.24.0 + uses: slackapi/slack-github-action@v1.26.0 with: channel-id: ${{ secrets.TASK_COMPLETE_SLACK_CHANNEL_ID }} payload: | From 74158deaddadd6956648ccef809cdc44262908d9 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:27:52 +0900 Subject: [PATCH 27/31] Create CNAME --- CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 CNAME diff --git a/CNAME b/CNAME new file mode 100644 index 000000000..01377533f --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +docs.cruru.kr \ No newline at end of file From 871772c22369f49e9a728ed72bb65f86f82137cd Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:28:12 +0900 Subject: [PATCH 28/31] Delete CNAME --- CNAME | 1 - 1 file changed, 1 deletion(-) delete mode 100644 CNAME diff --git a/CNAME b/CNAME deleted file mode 100644 index 01377533f..000000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs.cruru.kr \ No newline at end of file From 1773a5687d8bace93c27d20984fc798227686b9a Mon Sep 17 00:00:00 2001 From: Kwoun Ki Ho <73146678+Chocochip101@users.noreply.github.com> Date: Tue, 10 Sep 2024 10:23:39 +0900 Subject: [PATCH 29/31] Create CNAME --- docs/CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 000000000..01377533f --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +docs.cruru.kr \ No newline at end of file From 47cdda2c89491c5834b487810b08b70121dc4382 Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Wed, 11 Sep 2024 10:54:39 +0900 Subject: [PATCH 30/31] chore(Pages): Delete docs directory --- docs/CNAME | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 01377533f..000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs.cruru.kr \ No newline at end of file From 8fa579f01659c3732027bd9299c928b17694bc3d Mon Sep 17 00:00:00 2001 From: Do Yeop Kim <113661364+Dobby-Kim@users.noreply.github.com> Date: Thu, 26 Sep 2024 16:03:34 +0900 Subject: [PATCH 31/31] [BE] Release: Cruru v.1.1.0 (#728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kwoun Ki Ho <73146678+Chocochip101@users.noreply.github.com> Co-authored-by: leetaehoon Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: 김형호 <140397285+HyungHoKim00@users.noreply.github.com> Co-authored-by: cutehumanS2 Co-authored-by: HyungHoKim00 Co-authored-by: Kwoun Ki Ho Co-authored-by: Leetaehoon <66353672+xogns1514@users.noreply.github.com> Co-authored-by: 최가희 <60508828+cutehumanS2@users.noreply.github.com> --- .github/workflows/be-api-docs-page.yml | 59 +++ .github/workflows/be-cd_dev-docker.yml | 160 ++++++ .github/workflows/be-cd_prod-docker.yml | 140 +++++ .github/workflows/be-cd_test-docker.yml | 132 +++++ .../workflows/be-ci-pr-code-coverage-test.yml | 70 +++ .github/workflows/be-pr-event-alert.yml | 426 ++++++++++++++++ .gitignore | 76 +++ backend/Dockerfile | 11 + backend/README.md | 20 + backend/build.gradle | 105 ++++ backend/docker-compose.dev.yml | 61 +++ backend/docker-compose.prod.yml | 42 ++ backend/docker-compose.test.yml | 42 ++ backend/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + backend/gradlew | 249 +++++++++ backend/gradlew.bat | 92 ++++ backend/promtail-config.yml | 140 +++++ backend/settings.gradle | 9 + backend/src/docs/asciidoc/applicant.adoc | 69 +++ backend/src/docs/asciidoc/applyform.adoc | 59 +++ backend/src/docs/asciidoc/auth.adoc | 25 + backend/src/docs/asciidoc/club.adoc | 15 + backend/src/docs/asciidoc/dashboard.adoc | 21 + backend/src/docs/asciidoc/email.adoc | 15 + backend/src/docs/asciidoc/evaluation.adoc | 47 ++ backend/src/docs/asciidoc/index.adoc | 18 + backend/src/docs/asciidoc/member.adoc | 11 + backend/src/docs/asciidoc/process.adoc | 61 +++ backend/src/docs/asciidoc/question.adoc | 11 + .../src/main/java/com/cruru/BaseEntity.java | 21 + .../main/java/com/cruru/CruruApplication.java | 14 + .../src/main/java/com/cruru/DataLoader.java | 361 +++++++++++++ .../main/java/com/cruru/HomeController.java | 16 + .../com/cruru/advice/ConflictException.java | 12 + .../cruru/advice/CruruCustomException.java | 19 + .../com/cruru/advice/ForbiddenException.java | 13 + .../cruru/advice/GlobalExceptionHandler.java | 76 +++ .../cruru/advice/InternalServerException.java | 17 + .../com/cruru/advice/NotFoundException.java | 13 + .../cruru/advice/UnauthorizedException.java | 12 + .../advice/UncatchedExceptionHandler.java | 36 ++ .../badrequest/BadRequestException.java | 13 + .../advice/badrequest/TextBlankException.java | 10 + .../badrequest/TextCharacterException.java | 10 + .../badrequest/TextLengthException.java | 15 + .../controller/ApplicantController.java | 82 +++ .../controller/EvaluationController.java | 65 +++ .../request/ApplicantCreateRequest.java | 18 + .../request/ApplicantMoveRequest.java | 11 + .../request/ApplicantUpdateRequest.java | 16 + .../request/EvaluationCreateRequest.java | 15 + .../request/EvaluationUpdateRequest.java | 15 + .../response/ApplicantAnswerResponses.java | 12 + .../response/ApplicantBasicResponse.java | 14 + .../response/ApplicantCardResponse.java | 22 + .../response/ApplicantResponse.java | 19 + .../response/EvaluationResponse.java | 18 + .../response/EvaluationResponses.java | 11 + .../com/cruru/applicant/domain/Applicant.java | 162 ++++++ .../cruru/applicant/domain/Evaluation.java | 98 ++++ .../applicant/domain/dto/ApplicantCard.java | 25 + .../repository/ApplicantRepository.java | 42 ++ .../repository/EvaluationRepository.java | 14 + .../exception/ApplicantNotFoundException.java | 12 + .../EvaluationNotFoundException.java | 12 + .../ApplicantIllegalPhoneNumberException.java | 12 + .../ApplicantNameBlankException.java | 12 + .../ApplicantNameCharacterException.java | 12 + .../ApplicantNameLengthException.java | 12 + .../badrequest/ApplicantRejectException.java | 12 + .../ApplicantUnrejectException.java | 12 + .../badrequest/EvaluationScoreException.java | 12 + .../applicant/facade/ApplicantFacade.java | 68 +++ .../applicant/facade/EvaluationFacade.java | 64 +++ .../applicant/service/ApplicantService.java | 105 ++++ .../applicant/service/EvaluationService.java | 58 +++ .../controller/ApplyFormController.java | 49 ++ .../request/AnswerCreateRequest.java | 16 + .../request/ApplyFormSubmitRequest.java | 22 + .../request/ApplyFormWriteRequest.java | 23 + .../response/ApplyFormResponse.java | 22 + .../com/cruru/applyform/domain/ApplyForm.java | 110 ++++ .../repository/ApplyFormRepository.java | 31 ++ .../exception/ApplyFormNotFoundException.java | 12 + .../ApplyFormSubmitOutOfPeriodException.java | 12 + .../PersonalDataCollectDisagreeException.java | 12 + .../badrequest/ReplyNotExistsException.java | 12 + .../StartDateAfterEndDateException.java | 13 + .../badrequest/StartDatePastException.java | 13 + .../applyform/facade/ApplyFormFacade.java | 104 ++++ .../applyform/service/ApplyFormService.java | 89 ++++ .../auth/annotation/RequireAuthCheck.java | 16 + .../cruru/auth/aspect/AuthCheckAspect.java | 111 ++++ .../cruru/auth/controller/AuthController.java | 44 ++ .../auth/controller/request/LoginRequest.java | 15 + .../controller/response/LoginResponse.java | 11 + .../exception/IllegalCookieException.java | 12 + .../auth/exception/IllegalTokenException.java | 12 + .../auth/exception/LoginExpiredException.java | 12 + .../auth/exception/LoginFailedException.java | 12 + .../exception/LoginUnauthorizedException.java | 12 + .../com/cruru/auth/facade/AuthFacade.java | 27 + .../auth/security/PasswordValidator.java | 19 + .../cruru/auth/security/TokenProperties.java | 8 + .../cruru/auth/security/TokenProvider.java | 13 + .../auth/security/jwt/JwtTokenProvider.java | 62 +++ .../com/cruru/auth/service/AuthService.java | 59 +++ .../java/com/cruru/auth/util/AuthChecker.java | 21 + .../com/cruru/auth/util/SecureResource.java | 8 + .../cruru/club/controller/ClubController.java | 30 ++ .../controller/request/ClubCreateRequest.java | 10 + .../main/java/com/cruru/club/domain/Club.java | 104 ++++ .../domain/repository/ClubRepository.java | 11 + .../club/exception/ClubNotFoundException.java | 12 + .../badrequest/ClubNameBlankException.java | 12 + .../ClubNameCharacterException.java | 12 + .../badrequest/ClubNameLengthException.java | 12 + .../com/cruru/club/facade/ClubFacade.java | 33 ++ .../com/cruru/club/service/ClubService.java | 38 ++ .../java/com/cruru/config/AsyncConfig.java | 19 + .../java/com/cruru/config/ClockConfig.java | 15 + .../com/cruru/config/DataSourceConfig.java | 59 +++ .../com/cruru/config/DataSourceRouter.java | 18 + .../com/cruru/config/JpaAuditingConfig.java | 10 + .../java/com/cruru/config/WebMvcConfig.java | 52 ++ .../controller/DashboardController.java | 50 ++ .../request/DashboardCreateRequest.java | 30 ++ .../response/DashboardCreateResponse.java | 8 + .../response/DashboardPreviewResponse.java | 24 + .../response/DashboardsOfClubResponse.java | 14 + .../controller/response/StatsResponse.java | 13 + .../com/cruru/dashboard/domain/Dashboard.java | 68 +++ .../domain/DashboardApplyFormDto.java | 7 + .../repository/DashboardRepository.java | 8 + .../exception/DashboardNotFoundException.java | 12 + .../dashboard/facade/DashboardFacade.java | 123 +++++ .../dashboard/service/DashboardService.java | 51 ++ .../email/controller/EmailController.java | 25 + .../email/controller/dto/EmailRequest.java | 27 + .../java/com/cruru/email/domain/Email.java | 102 ++++ .../domain/repository/EmailRepository.java | 8 + .../exception/EmailAttachmentsException.java | 12 + .../EmailContentLengthException.java | 12 + .../exception/EmailSendFailedException.java | 12 + .../EmailSubjectLengthException.java | 12 + .../com/cruru/email/facade/EmailFacade.java | 55 ++ .../com/cruru/email/service/EmailService.java | 77 +++ .../java/com/cruru/email/util/FileUtil.java | 46 ++ .../global/AuthenticationInterceptor.java | 55 ++ .../cruru/global/LoginArgumentResolver.java | 43 ++ .../java/com/cruru/global/LoginProfile.java | 7 + .../com/cruru/global/util/CookieManager.java | 60 +++ .../cruru/global/util/CookieProperties.java | 16 + .../cruru/global/util/ExceptionLogger.java | 68 +++ .../member/controller/MemberController.java | 26 + .../request/MemberCreateRequest.java | 21 + .../java/com/cruru/member/domain/Member.java | 90 ++++ .../com/cruru/member/domain/MemberRole.java | 8 + .../domain/repository/MemberRepository.java | 12 + .../MemberEmailDuplicatedException.java | 12 + .../exception/MemberNotFoundException.java | 12 + .../MemberIllegalPasswordException.java | 12 + .../MemberIllegalPhoneNumberException.java | 12 + .../MemberPasswordLengthException.java | 12 + .../com/cruru/member/facade/MemberFacade.java | 25 + .../cruru/member/service/MemberService.java | 65 +++ .../process/controller/ProcessController.java | 70 +++ .../request/ProcessCreateRequest.java | 22 + .../request/ProcessUpdateRequest.java | 16 + .../controller/response/ProcessResponse.java | 22 + .../controller/response/ProcessResponses.java | 16 + .../response/ProcessSimpleResponse.java | 8 + .../com/cruru/process/domain/Process.java | 146 ++++++ .../cruru/process/domain/ProcessFactory.java | 31 ++ .../com/cruru/process/domain/ProcessType.java | 17 + .../domain/repository/ProcessRepository.java | 15 + .../exception/ProcessNotFoundException.java | 12 + .../badrequest/ProcessCountException.java | 12 + .../ProcessDeleteFixedException.java | 12 + ...cessDeleteRemainingApplicantException.java | 12 + .../badrequest/ProcessNameBlankException.java | 12 + .../ProcessNameCharacterException.java | 12 + .../ProcessNameLengthException.java | 12 + .../cruru/process/facade/ProcessFacade.java | 94 ++++ .../cruru/process/service/ProcessService.java | 120 +++++ .../controller/QuestionController.java | 29 ++ .../request/ChoiceCreateRequest.java | 16 + .../request/QuestionCreateRequest.java | 27 + .../request/QuestionUpdateRequests.java | 13 + .../controller/response/AnswerResponse.java | 14 + .../controller/response/ChoiceResponse.java | 14 + .../controller/response/QuestionResponse.java | 22 + .../com/cruru/question/domain/Answer.java | 100 ++++ .../com/cruru/question/domain/Choice.java | 74 +++ .../com/cruru/question/domain/Question.java | 105 ++++ .../cruru/question/domain/QuestionType.java | 21 + .../domain/repository/AnswerRepository.java | 14 + .../domain/repository/ChoiceRepository.java | 13 + .../domain/repository/QuestionRepository.java | 11 + .../AnswerContentLengthException.java | 12 + .../exception/QuestionNotFoundException.java | 12 + .../QuestionUnmodifiableException.java | 13 + .../badrequest/ChoiceEmptyException.java | 12 + .../ChoiceIllegalSaveException.java | 12 + .../cruru/question/facade/QuestionFacade.java | 40 ++ .../cruru/question/service/AnswerService.java | 68 +++ .../cruru/question/service/ChoiceService.java | 60 +++ .../question/service/QuestionService.java | 92 ++++ backend/src/main/resources/application.yml | 318 ++++++++++++ backend/src/main/resources/banner.txt | 16 + .../db/migration/V1_1__init_constraints.sql | 54 ++ .../main/resources/db/migration/V1__init.sql | 125 +++++ .../V2_1_1__init_email_constraints.sql | 9 + .../V2_1_2__create_evaluation_index.sql | 2 + .../db/migration/V2_1__init_email.sql | 14 + .../db/migration/V2__delete_applyform_url.sql | 1 + backend/src/main/resources/logback.xml | 109 ++++ .../controller/ApplicantControllerTest.java | 369 ++++++++++++++ .../controller/EvaluationControllerTest.java | 362 +++++++++++++ .../cruru/applicant/domain/ApplicantTest.java | 140 +++++ .../applicant/domain/EvaluationTest.java | 24 + .../repository/ApplicantRepositoryTest.java | 225 ++++++++ .../repository/EvaluationRepositoryTest.java | 55 ++ .../applicant/facade/ApplicantFacadeTest.java | 193 +++++++ .../facade/EvaluationFacadeTest.java | 106 ++++ .../service/ApplicantServiceTest.java | 202 ++++++++ .../service/EvaluationServiceTest.java | 114 +++++ .../controller/ApplyFormControllerTest.java | 480 ++++++++++++++++++ .../cruru/applyform/domain/ApplyFormTest.java | 27 + .../repository/ApplyFormRepositoryTest.java | 119 +++++ .../applyform/facade/ApplyFormFacadeTest.java | 218 ++++++++ .../service/ApplyFormServiceTest.java | 160 ++++++ .../auth/controller/AuthControllerTest.java | 134 +++++ .../auth/security/JwtTokenProviderTest.java | 114 +++++ .../club/controller/ClubControllerTest.java | 96 ++++ .../java/com/cruru/club/domain/ClubTest.java | 63 +++ .../domain/repository/ClubRepositoryTest.java | 54 ++ .../com/cruru/club/facade/ClubFacadeTest.java | 59 +++ .../cruru/club/service/ClubServiceTest.java | 83 +++ .../cruru/config/DataSourceRouterTest.java | 37 ++ .../controller/DashboardControllerTest.java | 252 +++++++++ .../repository/DashboardRepositoryTest.java | 38 ++ .../dashboard/facade/DashboardFacadeTest.java | 143 ++++++ .../service/DashboardServiceTest.java | 94 ++++ .../email/controller/EmailControllerTest.java | 124 +++++ .../com/cruru/email/domain/EmailTest.java | 50 ++ .../repository/EmailRepositoryTest.java | 61 +++ .../cruru/email/facade/EmailFacadeTest.java | 63 +++ .../cruru/email/service/EmailServiceTest.java | 43 ++ .../com/cruru/email/util/FileUtilTest.java | 66 +++ .../controller/MemberControllerTest.java | 79 +++ .../com/cruru/member/domain/MemberTest.java | 47 ++ .../repository/MemberRepositoryTest.java | 72 +++ .../cruru/member/facade/MemberFacadeTest.java | 45 ++ .../member/service/MemberServiceTest.java | 120 +++++ .../controller/ProcessControllerTest.java | 381 ++++++++++++++ .../com/cruru/process/domain/ProcessTest.java | 65 +++ .../repository/ProcessRepositoryTest.java | 57 +++ .../process/facade/ProcessFacadeTest.java | 142 ++++++ .../process/service/ProcessServiceTest.java | 175 +++++++ .../controller/QuestionControllerTest.java | 119 +++++ .../com/cruru/question/domain/AnswerTest.java | 44 ++ .../repository/AnswerRepositoryTest.java | 96 ++++ .../repository/ChoiceRepositoryTest.java | 53 ++ .../repository/QuestionRepositoryTest.java | 56 ++ .../question/facade/QuestionFacadeTest.java | 110 ++++ .../question/service/AnswerServiceTest.java | 169 ++++++ .../question/service/ChoiceServiceTest.java | 124 +++++ .../question/service/QuestionServiceTest.java | 153 ++++++ .../java/com/cruru/util/ControllerTest.java | 98 ++++ .../test/java/com/cruru/util/DbCleaner.java | 75 +++ .../java/com/cruru/util/RepositoryTest.java | 9 + .../test/java/com/cruru/util/ServiceTest.java | 77 +++ .../com/cruru/util/fixture/AnswerFixture.java | 20 + .../cruru/util/fixture/ApplicantFixture.java | 35 ++ .../cruru/util/fixture/ApplyFormFixture.java | 47 ++ .../com/cruru/util/fixture/ChoiceFixture.java | 38 ++ .../com/cruru/util/fixture/ClubFixture.java | 15 + .../cruru/util/fixture/DashboardFixture.java | 23 + .../com/cruru/util/fixture/EmailFixture.java | 35 ++ .../cruru/util/fixture/EvaluationFixture.java | 24 + .../cruru/util/fixture/LocalDateFixture.java | 29 ++ .../com/cruru/util/fixture/MemberFixture.java | 17 + .../cruru/util/fixture/ProcessFixture.java | 39 ++ .../cruru/util/fixture/QuestionFixture.java | 55 ++ backend/src/test/resources/application.yml | 49 ++ .../src/test/resources/static/email_test.txt | 1 + 288 files changed, 15990 insertions(+) create mode 100644 .github/workflows/be-api-docs-page.yml create mode 100644 .github/workflows/be-cd_dev-docker.yml create mode 100644 .github/workflows/be-cd_prod-docker.yml create mode 100644 .github/workflows/be-cd_test-docker.yml create mode 100644 .github/workflows/be-ci-pr-code-coverage-test.yml create mode 100644 .github/workflows/be-pr-event-alert.yml create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/build.gradle create mode 100644 backend/docker-compose.dev.yml create mode 100644 backend/docker-compose.prod.yml create mode 100644 backend/docker-compose.test.yml create mode 100644 backend/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend/gradlew create mode 100644 backend/gradlew.bat create mode 100644 backend/promtail-config.yml create mode 100644 backend/settings.gradle create mode 100644 backend/src/docs/asciidoc/applicant.adoc create mode 100644 backend/src/docs/asciidoc/applyform.adoc create mode 100644 backend/src/docs/asciidoc/auth.adoc create mode 100644 backend/src/docs/asciidoc/club.adoc create mode 100644 backend/src/docs/asciidoc/dashboard.adoc create mode 100644 backend/src/docs/asciidoc/email.adoc create mode 100644 backend/src/docs/asciidoc/evaluation.adoc create mode 100644 backend/src/docs/asciidoc/index.adoc create mode 100644 backend/src/docs/asciidoc/member.adoc create mode 100644 backend/src/docs/asciidoc/process.adoc create mode 100644 backend/src/docs/asciidoc/question.adoc create mode 100644 backend/src/main/java/com/cruru/BaseEntity.java create mode 100644 backend/src/main/java/com/cruru/CruruApplication.java create mode 100644 backend/src/main/java/com/cruru/DataLoader.java create mode 100644 backend/src/main/java/com/cruru/HomeController.java create mode 100644 backend/src/main/java/com/cruru/advice/ConflictException.java create mode 100644 backend/src/main/java/com/cruru/advice/CruruCustomException.java create mode 100644 backend/src/main/java/com/cruru/advice/ForbiddenException.java create mode 100644 backend/src/main/java/com/cruru/advice/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/cruru/advice/InternalServerException.java create mode 100644 backend/src/main/java/com/cruru/advice/NotFoundException.java create mode 100644 backend/src/main/java/com/cruru/advice/UnauthorizedException.java create mode 100644 backend/src/main/java/com/cruru/advice/UncatchedExceptionHandler.java create mode 100644 backend/src/main/java/com/cruru/advice/badrequest/BadRequestException.java create mode 100644 backend/src/main/java/com/cruru/advice/badrequest/TextBlankException.java create mode 100644 backend/src/main/java/com/cruru/advice/badrequest/TextCharacterException.java create mode 100644 backend/src/main/java/com/cruru/advice/badrequest/TextLengthException.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/ApplicantController.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/EvaluationController.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/request/ApplicantCreateRequest.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/request/ApplicantMoveRequest.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/request/ApplicantUpdateRequest.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/request/EvaluationCreateRequest.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/request/EvaluationUpdateRequest.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/response/ApplicantAnswerResponses.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/response/ApplicantBasicResponse.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/response/ApplicantCardResponse.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/response/ApplicantResponse.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/response/EvaluationResponse.java create mode 100644 backend/src/main/java/com/cruru/applicant/controller/response/EvaluationResponses.java create mode 100644 backend/src/main/java/com/cruru/applicant/domain/Applicant.java create mode 100644 backend/src/main/java/com/cruru/applicant/domain/Evaluation.java create mode 100644 backend/src/main/java/com/cruru/applicant/domain/dto/ApplicantCard.java create mode 100644 backend/src/main/java/com/cruru/applicant/domain/repository/ApplicantRepository.java create mode 100644 backend/src/main/java/com/cruru/applicant/domain/repository/EvaluationRepository.java create mode 100644 backend/src/main/java/com/cruru/applicant/exception/ApplicantNotFoundException.java create mode 100644 backend/src/main/java/com/cruru/applicant/exception/EvaluationNotFoundException.java create mode 100644 backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantIllegalPhoneNumberException.java create mode 100644 backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameBlankException.java create mode 100644 backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameCharacterException.java create mode 100644 backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameLengthException.java create mode 100644 backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantRejectException.java create mode 100644 backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantUnrejectException.java create mode 100644 backend/src/main/java/com/cruru/applicant/exception/badrequest/EvaluationScoreException.java create mode 100644 backend/src/main/java/com/cruru/applicant/facade/ApplicantFacade.java create mode 100644 backend/src/main/java/com/cruru/applicant/facade/EvaluationFacade.java create mode 100644 backend/src/main/java/com/cruru/applicant/service/ApplicantService.java create mode 100644 backend/src/main/java/com/cruru/applicant/service/EvaluationService.java create mode 100644 backend/src/main/java/com/cruru/applyform/controller/ApplyFormController.java create mode 100644 backend/src/main/java/com/cruru/applyform/controller/request/AnswerCreateRequest.java create mode 100644 backend/src/main/java/com/cruru/applyform/controller/request/ApplyFormSubmitRequest.java create mode 100644 backend/src/main/java/com/cruru/applyform/controller/request/ApplyFormWriteRequest.java create mode 100644 backend/src/main/java/com/cruru/applyform/controller/response/ApplyFormResponse.java create mode 100644 backend/src/main/java/com/cruru/applyform/domain/ApplyForm.java create mode 100644 backend/src/main/java/com/cruru/applyform/domain/repository/ApplyFormRepository.java create mode 100644 backend/src/main/java/com/cruru/applyform/exception/ApplyFormNotFoundException.java create mode 100644 backend/src/main/java/com/cruru/applyform/exception/badrequest/ApplyFormSubmitOutOfPeriodException.java create mode 100644 backend/src/main/java/com/cruru/applyform/exception/badrequest/PersonalDataCollectDisagreeException.java create mode 100644 backend/src/main/java/com/cruru/applyform/exception/badrequest/ReplyNotExistsException.java create mode 100644 backend/src/main/java/com/cruru/applyform/exception/badrequest/StartDateAfterEndDateException.java create mode 100644 backend/src/main/java/com/cruru/applyform/exception/badrequest/StartDatePastException.java create mode 100644 backend/src/main/java/com/cruru/applyform/facade/ApplyFormFacade.java create mode 100644 backend/src/main/java/com/cruru/applyform/service/ApplyFormService.java create mode 100644 backend/src/main/java/com/cruru/auth/annotation/RequireAuthCheck.java create mode 100644 backend/src/main/java/com/cruru/auth/aspect/AuthCheckAspect.java create mode 100644 backend/src/main/java/com/cruru/auth/controller/AuthController.java create mode 100644 backend/src/main/java/com/cruru/auth/controller/request/LoginRequest.java create mode 100644 backend/src/main/java/com/cruru/auth/controller/response/LoginResponse.java create mode 100644 backend/src/main/java/com/cruru/auth/exception/IllegalCookieException.java create mode 100644 backend/src/main/java/com/cruru/auth/exception/IllegalTokenException.java create mode 100644 backend/src/main/java/com/cruru/auth/exception/LoginExpiredException.java create mode 100644 backend/src/main/java/com/cruru/auth/exception/LoginFailedException.java create mode 100644 backend/src/main/java/com/cruru/auth/exception/LoginUnauthorizedException.java create mode 100644 backend/src/main/java/com/cruru/auth/facade/AuthFacade.java create mode 100644 backend/src/main/java/com/cruru/auth/security/PasswordValidator.java create mode 100644 backend/src/main/java/com/cruru/auth/security/TokenProperties.java create mode 100644 backend/src/main/java/com/cruru/auth/security/TokenProvider.java create mode 100644 backend/src/main/java/com/cruru/auth/security/jwt/JwtTokenProvider.java create mode 100644 backend/src/main/java/com/cruru/auth/service/AuthService.java create mode 100644 backend/src/main/java/com/cruru/auth/util/AuthChecker.java create mode 100644 backend/src/main/java/com/cruru/auth/util/SecureResource.java create mode 100644 backend/src/main/java/com/cruru/club/controller/ClubController.java create mode 100644 backend/src/main/java/com/cruru/club/controller/request/ClubCreateRequest.java create mode 100644 backend/src/main/java/com/cruru/club/domain/Club.java create mode 100644 backend/src/main/java/com/cruru/club/domain/repository/ClubRepository.java create mode 100644 backend/src/main/java/com/cruru/club/exception/ClubNotFoundException.java create mode 100644 backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameBlankException.java create mode 100644 backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameCharacterException.java create mode 100644 backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameLengthException.java create mode 100644 backend/src/main/java/com/cruru/club/facade/ClubFacade.java create mode 100644 backend/src/main/java/com/cruru/club/service/ClubService.java create mode 100644 backend/src/main/java/com/cruru/config/AsyncConfig.java create mode 100644 backend/src/main/java/com/cruru/config/ClockConfig.java create mode 100644 backend/src/main/java/com/cruru/config/DataSourceConfig.java create mode 100644 backend/src/main/java/com/cruru/config/DataSourceRouter.java create mode 100644 backend/src/main/java/com/cruru/config/JpaAuditingConfig.java create mode 100644 backend/src/main/java/com/cruru/config/WebMvcConfig.java create mode 100644 backend/src/main/java/com/cruru/dashboard/controller/DashboardController.java create mode 100644 backend/src/main/java/com/cruru/dashboard/controller/request/DashboardCreateRequest.java create mode 100644 backend/src/main/java/com/cruru/dashboard/controller/response/DashboardCreateResponse.java create mode 100644 backend/src/main/java/com/cruru/dashboard/controller/response/DashboardPreviewResponse.java create mode 100644 backend/src/main/java/com/cruru/dashboard/controller/response/DashboardsOfClubResponse.java create mode 100644 backend/src/main/java/com/cruru/dashboard/controller/response/StatsResponse.java create mode 100644 backend/src/main/java/com/cruru/dashboard/domain/Dashboard.java create mode 100644 backend/src/main/java/com/cruru/dashboard/domain/DashboardApplyFormDto.java create mode 100644 backend/src/main/java/com/cruru/dashboard/domain/repository/DashboardRepository.java create mode 100644 backend/src/main/java/com/cruru/dashboard/exception/DashboardNotFoundException.java create mode 100644 backend/src/main/java/com/cruru/dashboard/facade/DashboardFacade.java create mode 100644 backend/src/main/java/com/cruru/dashboard/service/DashboardService.java create mode 100644 backend/src/main/java/com/cruru/email/controller/EmailController.java create mode 100644 backend/src/main/java/com/cruru/email/controller/dto/EmailRequest.java create mode 100644 backend/src/main/java/com/cruru/email/domain/Email.java create mode 100644 backend/src/main/java/com/cruru/email/domain/repository/EmailRepository.java create mode 100644 backend/src/main/java/com/cruru/email/exception/EmailAttachmentsException.java create mode 100644 backend/src/main/java/com/cruru/email/exception/EmailContentLengthException.java create mode 100644 backend/src/main/java/com/cruru/email/exception/EmailSendFailedException.java create mode 100644 backend/src/main/java/com/cruru/email/exception/EmailSubjectLengthException.java create mode 100644 backend/src/main/java/com/cruru/email/facade/EmailFacade.java create mode 100644 backend/src/main/java/com/cruru/email/service/EmailService.java create mode 100644 backend/src/main/java/com/cruru/email/util/FileUtil.java create mode 100644 backend/src/main/java/com/cruru/global/AuthenticationInterceptor.java create mode 100644 backend/src/main/java/com/cruru/global/LoginArgumentResolver.java create mode 100644 backend/src/main/java/com/cruru/global/LoginProfile.java create mode 100644 backend/src/main/java/com/cruru/global/util/CookieManager.java create mode 100644 backend/src/main/java/com/cruru/global/util/CookieProperties.java create mode 100644 backend/src/main/java/com/cruru/global/util/ExceptionLogger.java create mode 100644 backend/src/main/java/com/cruru/member/controller/MemberController.java create mode 100644 backend/src/main/java/com/cruru/member/controller/request/MemberCreateRequest.java create mode 100644 backend/src/main/java/com/cruru/member/domain/Member.java create mode 100644 backend/src/main/java/com/cruru/member/domain/MemberRole.java create mode 100644 backend/src/main/java/com/cruru/member/domain/repository/MemberRepository.java create mode 100644 backend/src/main/java/com/cruru/member/exception/MemberEmailDuplicatedException.java create mode 100644 backend/src/main/java/com/cruru/member/exception/MemberNotFoundException.java create mode 100644 backend/src/main/java/com/cruru/member/exception/badrequest/MemberIllegalPasswordException.java create mode 100644 backend/src/main/java/com/cruru/member/exception/badrequest/MemberIllegalPhoneNumberException.java create mode 100644 backend/src/main/java/com/cruru/member/exception/badrequest/MemberPasswordLengthException.java create mode 100644 backend/src/main/java/com/cruru/member/facade/MemberFacade.java create mode 100644 backend/src/main/java/com/cruru/member/service/MemberService.java create mode 100644 backend/src/main/java/com/cruru/process/controller/ProcessController.java create mode 100644 backend/src/main/java/com/cruru/process/controller/request/ProcessCreateRequest.java create mode 100644 backend/src/main/java/com/cruru/process/controller/request/ProcessUpdateRequest.java create mode 100644 backend/src/main/java/com/cruru/process/controller/response/ProcessResponse.java create mode 100644 backend/src/main/java/com/cruru/process/controller/response/ProcessResponses.java create mode 100644 backend/src/main/java/com/cruru/process/controller/response/ProcessSimpleResponse.java create mode 100644 backend/src/main/java/com/cruru/process/domain/Process.java create mode 100644 backend/src/main/java/com/cruru/process/domain/ProcessFactory.java create mode 100644 backend/src/main/java/com/cruru/process/domain/ProcessType.java create mode 100644 backend/src/main/java/com/cruru/process/domain/repository/ProcessRepository.java create mode 100644 backend/src/main/java/com/cruru/process/exception/ProcessNotFoundException.java create mode 100644 backend/src/main/java/com/cruru/process/exception/badrequest/ProcessCountException.java create mode 100644 backend/src/main/java/com/cruru/process/exception/badrequest/ProcessDeleteFixedException.java create mode 100644 backend/src/main/java/com/cruru/process/exception/badrequest/ProcessDeleteRemainingApplicantException.java create mode 100644 backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameBlankException.java create mode 100644 backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameCharacterException.java create mode 100644 backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameLengthException.java create mode 100644 backend/src/main/java/com/cruru/process/facade/ProcessFacade.java create mode 100644 backend/src/main/java/com/cruru/process/service/ProcessService.java create mode 100644 backend/src/main/java/com/cruru/question/controller/QuestionController.java create mode 100644 backend/src/main/java/com/cruru/question/controller/request/ChoiceCreateRequest.java create mode 100644 backend/src/main/java/com/cruru/question/controller/request/QuestionCreateRequest.java create mode 100644 backend/src/main/java/com/cruru/question/controller/request/QuestionUpdateRequests.java create mode 100644 backend/src/main/java/com/cruru/question/controller/response/AnswerResponse.java create mode 100644 backend/src/main/java/com/cruru/question/controller/response/ChoiceResponse.java create mode 100644 backend/src/main/java/com/cruru/question/controller/response/QuestionResponse.java create mode 100644 backend/src/main/java/com/cruru/question/domain/Answer.java create mode 100644 backend/src/main/java/com/cruru/question/domain/Choice.java create mode 100644 backend/src/main/java/com/cruru/question/domain/Question.java create mode 100644 backend/src/main/java/com/cruru/question/domain/QuestionType.java create mode 100644 backend/src/main/java/com/cruru/question/domain/repository/AnswerRepository.java create mode 100644 backend/src/main/java/com/cruru/question/domain/repository/ChoiceRepository.java create mode 100644 backend/src/main/java/com/cruru/question/domain/repository/QuestionRepository.java create mode 100644 backend/src/main/java/com/cruru/question/exception/AnswerContentLengthException.java create mode 100644 backend/src/main/java/com/cruru/question/exception/QuestionNotFoundException.java create mode 100644 backend/src/main/java/com/cruru/question/exception/QuestionUnmodifiableException.java create mode 100644 backend/src/main/java/com/cruru/question/exception/badrequest/ChoiceEmptyException.java create mode 100644 backend/src/main/java/com/cruru/question/exception/badrequest/ChoiceIllegalSaveException.java create mode 100644 backend/src/main/java/com/cruru/question/facade/QuestionFacade.java create mode 100644 backend/src/main/java/com/cruru/question/service/AnswerService.java create mode 100644 backend/src/main/java/com/cruru/question/service/ChoiceService.java create mode 100644 backend/src/main/java/com/cruru/question/service/QuestionService.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/main/resources/banner.txt create mode 100644 backend/src/main/resources/db/migration/V1_1__init_constraints.sql create mode 100644 backend/src/main/resources/db/migration/V1__init.sql create mode 100644 backend/src/main/resources/db/migration/V2_1_1__init_email_constraints.sql create mode 100644 backend/src/main/resources/db/migration/V2_1_2__create_evaluation_index.sql create mode 100644 backend/src/main/resources/db/migration/V2_1__init_email.sql create mode 100644 backend/src/main/resources/db/migration/V2__delete_applyform_url.sql create mode 100644 backend/src/main/resources/logback.xml create mode 100644 backend/src/test/java/com/cruru/applicant/controller/ApplicantControllerTest.java create mode 100644 backend/src/test/java/com/cruru/applicant/controller/EvaluationControllerTest.java create mode 100644 backend/src/test/java/com/cruru/applicant/domain/ApplicantTest.java create mode 100644 backend/src/test/java/com/cruru/applicant/domain/EvaluationTest.java create mode 100644 backend/src/test/java/com/cruru/applicant/domain/repository/ApplicantRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/applicant/domain/repository/EvaluationRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/applicant/facade/ApplicantFacadeTest.java create mode 100644 backend/src/test/java/com/cruru/applicant/facade/EvaluationFacadeTest.java create mode 100644 backend/src/test/java/com/cruru/applicant/service/ApplicantServiceTest.java create mode 100644 backend/src/test/java/com/cruru/applicant/service/EvaluationServiceTest.java create mode 100644 backend/src/test/java/com/cruru/applyform/controller/ApplyFormControllerTest.java create mode 100644 backend/src/test/java/com/cruru/applyform/domain/ApplyFormTest.java create mode 100644 backend/src/test/java/com/cruru/applyform/domain/repository/ApplyFormRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/applyform/facade/ApplyFormFacadeTest.java create mode 100644 backend/src/test/java/com/cruru/applyform/service/ApplyFormServiceTest.java create mode 100644 backend/src/test/java/com/cruru/auth/controller/AuthControllerTest.java create mode 100644 backend/src/test/java/com/cruru/auth/security/JwtTokenProviderTest.java create mode 100644 backend/src/test/java/com/cruru/club/controller/ClubControllerTest.java create mode 100644 backend/src/test/java/com/cruru/club/domain/ClubTest.java create mode 100644 backend/src/test/java/com/cruru/club/domain/repository/ClubRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/club/facade/ClubFacadeTest.java create mode 100644 backend/src/test/java/com/cruru/club/service/ClubServiceTest.java create mode 100644 backend/src/test/java/com/cruru/config/DataSourceRouterTest.java create mode 100644 backend/src/test/java/com/cruru/dashboard/controller/DashboardControllerTest.java create mode 100644 backend/src/test/java/com/cruru/dashboard/domain/repository/DashboardRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/dashboard/facade/DashboardFacadeTest.java create mode 100644 backend/src/test/java/com/cruru/dashboard/service/DashboardServiceTest.java create mode 100644 backend/src/test/java/com/cruru/email/controller/EmailControllerTest.java create mode 100644 backend/src/test/java/com/cruru/email/domain/EmailTest.java create mode 100644 backend/src/test/java/com/cruru/email/domain/repository/EmailRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/email/facade/EmailFacadeTest.java create mode 100644 backend/src/test/java/com/cruru/email/service/EmailServiceTest.java create mode 100644 backend/src/test/java/com/cruru/email/util/FileUtilTest.java create mode 100644 backend/src/test/java/com/cruru/member/controller/MemberControllerTest.java create mode 100644 backend/src/test/java/com/cruru/member/domain/MemberTest.java create mode 100644 backend/src/test/java/com/cruru/member/domain/repository/MemberRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/member/facade/MemberFacadeTest.java create mode 100644 backend/src/test/java/com/cruru/member/service/MemberServiceTest.java create mode 100644 backend/src/test/java/com/cruru/process/controller/ProcessControllerTest.java create mode 100644 backend/src/test/java/com/cruru/process/domain/ProcessTest.java create mode 100644 backend/src/test/java/com/cruru/process/domain/repository/ProcessRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/process/facade/ProcessFacadeTest.java create mode 100644 backend/src/test/java/com/cruru/process/service/ProcessServiceTest.java create mode 100644 backend/src/test/java/com/cruru/question/controller/QuestionControllerTest.java create mode 100644 backend/src/test/java/com/cruru/question/domain/AnswerTest.java create mode 100644 backend/src/test/java/com/cruru/question/domain/repository/AnswerRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/question/domain/repository/ChoiceRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/question/domain/repository/QuestionRepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/question/facade/QuestionFacadeTest.java create mode 100644 backend/src/test/java/com/cruru/question/service/AnswerServiceTest.java create mode 100644 backend/src/test/java/com/cruru/question/service/ChoiceServiceTest.java create mode 100644 backend/src/test/java/com/cruru/question/service/QuestionServiceTest.java create mode 100644 backend/src/test/java/com/cruru/util/ControllerTest.java create mode 100644 backend/src/test/java/com/cruru/util/DbCleaner.java create mode 100644 backend/src/test/java/com/cruru/util/RepositoryTest.java create mode 100644 backend/src/test/java/com/cruru/util/ServiceTest.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/AnswerFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/ApplicantFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/ApplyFormFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/ChoiceFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/ClubFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/DashboardFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/EmailFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/EvaluationFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/LocalDateFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/MemberFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/ProcessFixture.java create mode 100644 backend/src/test/java/com/cruru/util/fixture/QuestionFixture.java create mode 100644 backend/src/test/resources/application.yml create mode 100644 backend/src/test/resources/static/email_test.txt diff --git a/.github/workflows/be-api-docs-page.yml b/.github/workflows/be-api-docs-page.yml new file mode 100644 index 000000000..a038af659 --- /dev/null +++ b/.github/workflows/be-api-docs-page.yml @@ -0,0 +1,59 @@ +name: BE/DOCS - API Docs Build & Deploy + +on: + workflow_dispatch: + push: + branches: be/develop + +permissions: + contents: write + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - name: Checkout Latest + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build RestDocs with Gradle + run: ./gradlew asciidoctor + + - name: Setup Pages + uses: actions/configure-pages@v3 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ${{ github.workspace }}/backend/build/docs/asciidoc + + deploy: + needs: build + runs-on: ubuntu-latest + + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy API Docs to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/be-cd_dev-docker.yml b/.github/workflows/be-cd_dev-docker.yml new file mode 100644 index 000000000..7a970c1fa --- /dev/null +++ b/.github/workflows/be-cd_dev-docker.yml @@ -0,0 +1,160 @@ +name: BE/CD - [DEV] Build & Deploy + +on: + workflow_dispatch: + push: + branches: be/develop + +jobs: + build: + environment: dev + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set timezone to Korea + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: "Asia/Seoul" + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-write-only: true + + - name: Build with Gradle + run: ./gradlew bootJar + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get current date and time + id: datetime + run: | + echo "datetime=$(date +'%Y%m%d%H%M%S')" >> "$GITHUB_OUTPUT" + + - name: Image build and push + run: | + docker build --build-arg PROFILE=dev -t ${{ secrets.DOCKER_REPO_NAME }}/cruru:dev-${{ steps.datetime.outputs.datetime }} --platform linux/arm64 . + docker push ${{ secrets.DOCKER_REPO_NAME }}/cruru:dev-${{ steps.datetime.outputs.datetime }} + + - name: Upload docker-compose yaml script to artifact + uses: actions/upload-artifact@v4 + with: + name: docker-compose + path: | + ${{ github.workspace }}/backend/docker-compose.dev.yml + ${{ github.workspace }}/backend/promtail-config.yml + outputs: + BUILD_VERSION: ${{ steps.datetime.outputs.datetime }} + + deploy: + environment: dev + runs-on: [self-hosted, be-dev] + needs: build + defaults: + run: + working-directory: backend + steps: + - name: Set docker-compose YAML script to runner + uses: actions/download-artifact@v4 + with: + name: docker-compose + path: ${{ github.workspace }}/backend + + - name: Extract secrets as .env file + run: | + cat < .env + # Docker Hub info from Github Secrets + DOCKER_REPO_NAME=${{ secrets.DOCKER_REPO_NAME }} + DOCKER_IMAGE_VERSION_TAG=dev-${{ needs.build.outputs.BUILD_VERSION }} + + # DB Configuration secrets info from Github Secrets + MYSQL_DB_NAME=${{ secrets.MYSQL_DB_NAME }} + MYSQL_ROOT_HOST=${{ secrets.MYSQL_ROOT_HOST }} + MYSQL_TIME_ZONE=${{ secrets.MYSQL_TIME_ZONE }} + DB_PORT=${{ secrets.DB_PORT }} + DB_IP_ADDRESS=${{ secrets.DB_IP_ADDRESS }} + DB_URL=${{ secrets.DB_URL }} + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DDL_AUTO=${{ secrets.DDL_AUTO }} + + # DB server configuration secrets info from Github Secrets + APP_IP_ADDRESS=${{ secrets.APP_IP_ADDRESS }} + SERVER_BINDING_PORT=${{ secrets.SERVER_BINDING_PORT }} + SERVER_PORT=${{ secrets.SERVER_PORT }} + SUBNET=${{ secrets.SUBNET }} + + # Monitoring configuration server info from Github secrets + MONITORING_INSTANCE_ADDR_LOKI_PORT=${{ secrets.MONITORING_INSTANCE_ADDR_LOKI_PORT }} + MONITORING_BINDING_PORT=${{ secrets.MONITORING_BINDING_PORT }} + MONITORING_PORT=${{ secrets.MONITORING_PORT }} + MONITORING_BASE_PATH=${{ secrets.MONITORING_BASE_PATH }} + + # Apply-form post URL generating format + APPLY_POST_BASE_URL=${{ secrets.APPLY_POST_BASE_URL }} + + # Email Auth info + EMAIL_USERNAME=${{ secrets.EMAIL_USERNAME }} + EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }} + + # Security settings + JWT_TOKEN_SECRET_KEY=${{ secrets.JWT_TOKEN_SECRET_KEY }} + JWT_TOKEN_EXPIRE_CYCLE=${{ secrets.JWT_TOKEN_EXPIRE_CYCLE }} + JWT_SIGN_ALGORITHM=${{ secrets.JWT_SIGN_ALGORITHM }} + + # Cookie settings + COOKIE_ACCESS_TOKEN_KEY=${{ secrets.COOKIE_ACCESS_TOKEN_KEY }} + COOKIE_HTTP_ONLY=${{ secrets.COOKIE_HTTP_ONLY }} + COOKIE_SECURE=${{ secrets.COOKIE_SECURE }} + COOKIE_DOMAIN=${{ secrets.COOKIE_DOMAIN }} + COOKIE_PATH=${{ secrets.COOKIE_PATH }} + COOKIE_SAME_SITE=${{ secrets.COOKIE_SAME_SITE }} + COOKIE_MAX_AGE=${{ secrets.COOKIE_MAX_AGE }} + EOF + + # - name: Check if MySQL container is running + # id: mysql_running + # run: | + # if [ "$(sudo docker ps -q -f name=database-container)" ]; then + # echo "mysql_running=true" >> $GITHUB_ENV + # else + # echo "mysql_running=false" >> $GITHUB_ENV + # fi + + # - name: Start MySQL container if not running + # if: env.mysql_running == 'false' + # run: | + # sudo docker-compose --env-file .env -f docker-compose.dev.yml up -d database-mysql + + # - name: Stop and remove existing application container + # run: | + # sudo docker-compose -f docker-compose.dev.yml stop application + # sudo docker-compose -f docker-compose.dev.yml rm -f application + + # - name: Run application Server container + # run: | + # sudo docker-compose --env-file .env -f docker-compose.dev.yml up -d application + + - name: Stop and remove existing application container + run: | + sudo docker-compose -f docker-compose.dev.yml down --rmi all + + - name: Run application Server container + run: | + sudo docker-compose -f docker-compose.dev.yml up -d diff --git a/.github/workflows/be-cd_prod-docker.yml b/.github/workflows/be-cd_prod-docker.yml new file mode 100644 index 000000000..7f1ec30ca --- /dev/null +++ b/.github/workflows/be-cd_prod-docker.yml @@ -0,0 +1,140 @@ +name: BE/CD - [PROD] Build & Deploy + +on: + workflow_dispatch: + push: + branches: be/release + +jobs: + build: + environment: prod + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set timezone to Korea + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: "Asia/Seoul" + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-write-only: true + + - name: Build with Gradle + run: ./gradlew bootJar + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get current date and time + id: datetime + run: | + echo "datetime=$(date +'%Y%m%d%H%M%S')" >> "$GITHUB_OUTPUT" + + - name: Image build and push + run: | + docker build --build-arg PROFILE=prod -t ${{ secrets.DOCKER_REPO_NAME }}/cruru:prod-${{ steps.datetime.outputs.datetime }} --platform linux/arm64 . + docker push ${{ secrets.DOCKER_REPO_NAME }}/cruru:prod-${{ steps.datetime.outputs.datetime }} + + - name: Upload docker-compose yaml script to artifact + uses: actions/upload-artifact@v4 + with: + name: docker-compose + path: | + ${{ github.workspace }}/backend/docker-compose.prod.yml + ${{ github.workspace }}/backend/promtail-config.yml + outputs: + BUILD_VERSION: ${{ steps.datetime.outputs.datetime }} + + deploy: + environment: prod + strategy: + max-parallel: 2 + matrix: + runners: [be-prod-a, be-prod-b] + + runs-on: [self-hosted, '${{ matrix.runners }}'] + needs: build + defaults: + run: + working-directory: backend + steps: + - name: Set docker-compose YAML script to runner + uses: actions/download-artifact@v4 + with: + name: docker-compose + path: ${{ github.workspace }}/backend + + - name: Extract secrets as .env file + run: | + cat < .env + # Docker Hub info from Github Secrets + DOCKER_REPO_NAME=${{ secrets.DOCKER_REPO_NAME }} + DOCKER_IMAGE_VERSION_TAG=prod-${{ needs.build.outputs.BUILD_VERSION }} + + # DB Configuration secrets info from Github Secrets + DB_PORT=${{ secrets.DB_PORT }} + DB_IP_ADDRESS=${{ secrets.DB_IP_ADDRESS }} + READ_DB_URL=${{ secrets.READ_DB_URL }} + WRITE_DB_URL=${{ secrets.WRITE_DB_URL }} + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DDL_AUTO=${{ secrets.DDL_AUTO }} + + # DB server configuration secrets info from Github Secrets + APP_IP_ADDRESS=${{ secrets.APP_IP_ADDRESS }} + SERVER_BINDING_PORT=${{ secrets.SERVER_BINDING_PORT }} + SERVER_PORT=${{ secrets.SERVER_PORT }} + SUBNET=${{ secrets.SUBNET }} + + # Monitoring configuration server info from Github secrets + MONITORING_INSTANCE_ADDR_LOKI_PORT=${{ secrets.MONITORING_INSTANCE_ADDR_LOKI_PORT }} + MONITORING_BINDING_PORT=${{ secrets.MONITORING_BINDING_PORT }} + MONITORING_PORT=${{ secrets.MONITORING_PORT }} + MONITORING_BASE_PATH=${{ secrets.MONITORING_BASE_PATH }} + + # Apply configuration server info from Github secrets + APPLY_POST_BASE_URL=${{ secrets.APPLY_POST_BASE_URL }} + + # Email Auth info + EMAIL_USERNAME=${{ secrets.EMAIL_USERNAME }} + EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }} + + # Security settings + JWT_TOKEN_SECRET_KEY=${{ secrets.JWT_TOKEN_SECRET_KEY }} + JWT_TOKEN_EXPIRE_CYCLE=${{ secrets.JWT_TOKEN_EXPIRE_CYCLE }} + JWT_SIGN_ALGORITHM=${{ secrets.JWT_SIGN_ALGORITHM }} + + # Cookie settings + COOKIE_ACCESS_TOKEN_KEY=${{ secrets.COOKIE_ACCESS_TOKEN_KEY }} + COOKIE_HTTP_ONLY=${{ secrets.COOKIE_HTTP_ONLY }} + COOKIE_SECURE=${{ secrets.COOKIE_SECURE }} + COOKIE_DOMAIN=${{ secrets.COOKIE_DOMAIN }} + COOKIE_PATH=${{ secrets.COOKIE_PATH }} + COOKIE_SAME_SITE=${{ secrets.COOKIE_SAME_SITE }} + COOKIE_MAX_AGE=${{ secrets.COOKIE_MAX_AGE }} + EOF + + - name: Stop and remove existing containers + run: | + sudo docker-compose -f docker-compose.prod.yml down --rmi all + + - name: Deploy docker container + run: | + sudo docker-compose --env-file .env -f docker-compose.prod.yml up -d diff --git a/.github/workflows/be-cd_test-docker.yml b/.github/workflows/be-cd_test-docker.yml new file mode 100644 index 000000000..26b6a28a4 --- /dev/null +++ b/.github/workflows/be-cd_test-docker.yml @@ -0,0 +1,132 @@ +name: BE/CD - [TEST] Build & Deploy + +on: + workflow_dispatch: + push: + branches: be/main + +jobs: + build: + environment: test + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set timezone to Korea + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: "Asia/Seoul" + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build with Gradle + run: ./gradlew bootJar + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get current date and time + id: datetime + run: | + echo "datetime=$(date +'%Y%m%d%H%M%S')" >> "$GITHUB_OUTPUT" + + - name: Image build and push + run: | + docker build --build-arg PROFILE=test -t ${{ secrets.DOCKER_REPO_NAME }}/cruru:test-${{ steps.datetime.outputs.datetime }} --platform linux/arm64 . + docker push ${{ secrets.DOCKER_REPO_NAME }}/cruru:test-${{ steps.datetime.outputs.datetime }} + + - name: Upload docker-compose yaml script to artifact + uses: actions/upload-artifact@v4 + with: + name: docker-compose + path: | + ${{ github.workspace }}/backend/docker-compose.test.yml + ${{ github.workspace }}/backend/promtail-config.yml + outputs: + BUILD_VERSION: ${{ steps.datetime.outputs.datetime }} + + deploy: + environment: test + runs-on: [self-hosted, be-test] + needs: build + defaults: + run: + working-directory: backend + steps: + - name: Set docker-compose YAML script to runner + uses: actions/download-artifact@v4 + with: + name: docker-compose + path: ${{ github.workspace }}/backend + + - name: Extract secrets as .env file + run: | + cat < .env + # Docker Hub info from Github Secrets + DOCKER_REPO_NAME=${{ secrets.DOCKER_REPO_NAME }} + DOCKER_IMAGE_VERSION_TAG=test-${{ needs.build.outputs.BUILD_VERSION }} + + # DB Configuration secrets info from Github Secrets + DB_PORT=${{ secrets.DB_PORT }} + DB_IP_ADDRESS=${{ secrets.DB_IP_ADDRESS }} + DB_URL=${{ secrets.DB_URL }} + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DDL_AUTO=${{ secrets.DDL_AUTO }} + + # DB server configuration secrets info from Github Secrets + APP_IP_ADDRESS=${{ secrets.APP_IP_ADDRESS }} + SERVER_BINDING_PORT=${{ secrets.SERVER_BINDING_PORT }} + SERVER_PORT=${{ secrets.SERVER_PORT }} + SUBNET=${{ secrets.SUBNET }} + + # Monitoring configuration server info from Github secrets + MONITORING_INSTANCE_ADDR_LOKI_PORT=${{ secrets.MONITORING_INSTANCE_ADDR_LOKI_PORT }} + MONITORING_BINDING_PORT=${{ secrets.MONITORING_BINDING_PORT }} + MONITORING_PORT=${{ secrets.MONITORING_PORT }} + MONITORING_BASE_PATH=${{ secrets.MONITORING_BASE_PATH }} + + # Apply configuration server info from Github secrets + APPLY_POST_BASE_URL=${{ secrets.APPLY_POST_BASE_URL }} + + # Email Auth info + EMAIL_USERNAME=${{ secrets.EMAIL_USERNAME }} + EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }} + + # Security settings + JWT_TOKEN_SECRET_KEY=${{ secrets.JWT_TOKEN_SECRET_KEY }} + JWT_TOKEN_EXPIRE_CYCLE=${{ secrets.JWT_TOKEN_EXPIRE_CYCLE }} + JWT_SIGN_ALGORITHM=${{ secrets.JWT_SIGN_ALGORITHM }} + + # Cookie settings + COOKIE_ACCESS_TOKEN_KEY=${{ secrets.COOKIE_ACCESS_TOKEN_KEY }} + COOKIE_HTTP_ONLY=${{ secrets.COOKIE_HTTP_ONLY }} + COOKIE_SECURE=${{ secrets.COOKIE_SECURE }} + COOKIE_DOMAIN=${{ secrets.COOKIE_DOMAIN }} + COOKIE_PATH=${{ secrets.COOKIE_PATH }} + COOKIE_SAME_SITE=${{ secrets.COOKIE_SAME_SITE }} + COOKIE_MAX_AGE=${{ secrets.COOKIE_MAX_AGE }} + EOF + + - name: Stop and remove existing containers + run: | + sudo docker-compose -f docker-compose.test.yml down --rmi all + + - name: Deploy docker container + run: | + sudo docker-compose --env-file .env -f docker-compose.test.yml up -d diff --git a/.github/workflows/be-ci-pr-code-coverage-test.yml b/.github/workflows/be-ci-pr-code-coverage-test.yml new file mode 100644 index 000000000..b77c2625e --- /dev/null +++ b/.github/workflows/be-ci-pr-code-coverage-test.yml @@ -0,0 +1,70 @@ +name: BE/CI - Test Coverage 검증 + +on: + workflow_dispatch: + pull_request: + types: [opened, ready_for_review] + branches: + - be/develop + - be/main + - 'be-**' + +env: + Dobby-Kim: "U07BJABU6G1" + U07BJABU6G1: "BE팀 도비" + Chocochip101: "U07BUEJDS8G" + U07BUEJDS8G: "BE팀 초코칩" + xogns1514: "U07AZ26UC2J" + U07AZ26UC2J: "BE팀 러쉬" + cutehumanS2: "U07B88ZQDU4" + U07B88ZQDU4: "BE팀 냥인" + HyungHoKim00: "U07B5HBKZM1" + U07B5HBKZM1: "BE팀 명오" + +jobs: + test-coverage-pr-opened: + defaults: + run: + working-directory: ./backend + if: startsWith(github.head_ref, 'be-') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-write-only: true + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests and generate coverage report + env: + JAVA_HOME: ${{ steps.setup-java.outputs.java-home }} + run: ./gradlew test jacocoTestReport + continue-on-error: true + + - name: Verify test coverage + env: + JAVA_HOME: ${{ steps.setup-java.outputs.java-home }} + run: ./gradlew jacocoTestCoverageVerification + continue-on-error: true + + - name: 테스트 커버리지를 PR에 코멘트로 등록 + uses: madrapps/jacoco-report@v1.6.1 + with: + title: 📌 Test Coverage Report + paths: ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 80 + min-coverage-changed-files: 80 diff --git a/.github/workflows/be-pr-event-alert.yml b/.github/workflows/be-pr-event-alert.yml new file mode 100644 index 000000000..6d146a357 --- /dev/null +++ b/.github/workflows/be-pr-event-alert.yml @@ -0,0 +1,426 @@ +name: BE/PM - PR Review 슬랙 알림 + +on: + workflow_dispatch: + pull_request: + types: [ review_requested, ready_for_review ] + branches: + - main + - 'be/**' + - 'be-**' + + pull_request_review: + types: [ submitted ] + branches: + - main + - 'be/**' + - 'be-**' + +env: + Dobby-Kim: "BE팀 도비" + Chocochip101: "BE팀 초코칩" + xogns1514: "BE팀 러쉬" + cutehumanS2: "BE팀 냥인" + HyungHoKim00: "BE팀 명오" + +jobs: + find-slack-thread: + name: Slack Thread ID 검색 + runs-on: ubuntu-latest + steps: + - name: Find Comment & Slack Thread ID + uses: peter-evans/find-comment@v3 + id: thread-id + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-regex: '^\d{10}\.\d{6}$' + - name: Set Slack Thread ID + run: echo "slack-thread-ts=${{ steps.thread-id.outputs.comment-body }}" >> "$GITHUB_OUTPUT" + id: set-thread-id + outputs: + SLACK_THREAD_ID: ${{ steps.set-thread-id.outputs.slack-thread-ts }} + + set-slack-thread-if-not-exist-create: + name: Slack Thread ID 미존재시 Thread 생성 및 ID 등록 + needs: find-slack-thread + runs-on: ubuntu-latest + steps: + - name: Set reviewer and sender variables + if: ${{ !needs.find-slack-thread.outputs.SLACK_THREAD_ID }} + id: set-vars + run: | + ASSIGNEE_LOGIN=${{ github.event.pull_request.assignee.login }} + echo "ASSIGNEE_SLACK_ID=${ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} + echo "ASSIGNEE_NICKNAME=${{ env[github.event.pull_request.assignee.login] }}" >> $GITHUB_ENV + + - name: pr review 요청 -> 리뷰어에게 slack 멘션 알림 + if: ${{ !needs.find-slack-thread.outputs.SLACK_THREAD_ID }} + id: send-message + uses: slackapi/slack-github-action@v1.26.0 + with: + channel-id: ${{ secrets.REVIEW_MENTION_CHANNEL_ID }} + payload: | + { + "blocks": [ + { + "type": "divider" + }, + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🎁 (#${{ github.event.pull_request.number }}) Pull Request가 준비되었습니다!", + "emoji": true + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\n*진행자:*\n${{ env.ASSIGNEE_NICKNAME }}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*${{ github.event.pull_request.title }}*" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "👉🏻 PR 바로가기 👈🏻", + "emoji": true + }, + "value": "바로가기 링크", + "url": "${{ github.event.pull_request.html_url }}", + "action_id": "button-action" + } + }, + { + "type": "divider" + } + ], + "icon_url": "${{ github.event.sender.avatar_url }}" + } + + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + + - name: Slack Thread ID를 PR comment에 등록 + if: ${{ !needs.find-slack-thread.outputs.SLACK_THREAD_ID }} + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.number }} + body: | + ${{ steps.send-message.outputs.ts }} + + - name: 생성되거나 이미 존재하는 Slack Thread ID 전달 + id: final-thread-id + run: | + if [ -n "${{ needs.find-slack-thread.outputs.SLACK_THREAD_ID }}" ]; then + echo "slack-thread-ts=${{ needs.find-slack-thread.outputs.SLACK_THREAD_ID }}" >> "$GITHUB_OUTPUT" + else + echo "slack-thread-ts=${{ steps.send-message.outputs.ts }}" >> "$GITHUB_OUTPUT" + fi + + outputs: + SLACK_THREAD_ID: ${{ steps.final-thread-id.outputs.slack-thread-ts }} + + review-requested_requested: + name: Slack Thread로 리뷰 요청 멘션 댓글 생성 + needs: set-slack-thread-if-not-exist-create + if: github.event.action == 'review_requested' + runs-on: ubuntu-latest + steps: + - name: Set reviewer and sender variables + id: set-vars + run: | + SENDER_LOGIN=${{ github.event.sender.login }} + echo "SENDER_SLACK_ID=${SENDER_LOGIN@L}" >> ${GITHUB_ENV} + echo "SENDER_NICKNAME=${{ env[github.event.sender.login] }}" >> $GITHUB_ENV + REVIEWER_LOGIN=${{ github.event.requested_reviewer.login }} + echo "REVIEWER_SLACK_ID=${REVIEWER_LOGIN@L}" >> ${GITHUB_ENV} + echo "REVIEWER_NICKNAME=${{ env[github.event.requested_reviewer.login] }}" >> $GITHUB_ENV + + + - name: pr review 요청 -> 리뷰어에게 slack 멘션 알림 + uses: slackapi/slack-github-action@v1.26.0 + with: + channel-id: ${{ secrets.REVIEW_MENTION_CHANNEL_ID }} + payload: | + { + "thread_ts": "${{ needs.set-slack-thread-if-not-exist-create.outputs.SLACK_THREAD_ID }}", + "blocks": [ + { + "type": "divider" + }, + { + "type": "header", + "text": { + "type": "plain_text", + "text": "✨ 리뷰 요청 ✨", + "emoji": true + } + }, + { + "type": "context", + "elements": [ + { + "type": "plain_text", + "text": "from.", + "emoji": true + }, + { + "type": "image", + "image_url": "${{ github.event.sender.avatar_url }}", + "alt_text": "" + }, + { + "type": "plain_text", + "text": "${{ env.SENDER_NICKNAME }}", + "emoji": true + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "➡️ <@${{ env.REVIEWER_SLACK_ID }}>" + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "💡*PR 제목:*\n${{ github.event.pull_request.title }}" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "⚡️ PR 바로가기 ⚡️", + "emoji": true + }, + "value": "PR_LINK", + "url": "${{ github.event.pull_request.html_url }}", + "action_id": "actionId-1" + } + ] + }, + { + "type": "divider" + } + ], + "icon_url": "${{ github.event.sender.avatar_url }}" + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + + review-submitted_alert: + name: Slack Thread로 리뷰 완료 멘션 댓글 생성 + needs: set-slack-thread-if-not-exist-create + if: ( github.event.review.state == 'CHANGES_REQUESTED' || github.event.review.state == 'COMMENTED' ) && github.event.sender.login != github.event.pull_request.assignee.login + runs-on: ubuntu-latest + steps: + - name: Set reviewer and reviewee variables + id: set-vars + run: | + ASSIGNEE_LOGIN=${{ github.event.pull_request.assignee.login }} + echo "ASSIGNEE_SLACK_ID=${ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} + echo "ASSIGNEE_NICKNAME=${{ env[github.event.pull_request.assignee.login] }}" >> $GITHUB_ENV + REVIEWER_LOGIN=${{ github.event.sender.login }} + echo "REVIEWER_SLACK_ID=${REVIEWER_LOGIN@L}" >> ${GITHUB_ENV} + echo "REVIEWER_NICKNAME=${{ env[github.event.sender.login] }}" >> $GITHUB_ENV + + - name: pr 리뷰 submit -> assignee에게 slack 멘션 알림 + if: env.ASSIGNEE_SLACK_ID != '' + uses: slackapi/slack-github-action@v1.24.0 + with: + channel-id: ${{ secrets.REVIEW_MENTION_CHANNEL_ID }} + payload: | + { + "thread_ts": "${{ needs.set-slack-thread-if-not-exist-create.outputs.SLACK_THREAD_ID }}", + "blocks": [ + { + "type": "divider" + }, + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🔥리뷰 완료🔥", + "emoji": true + } + }, + { + "type": "context", + "elements": [ + { + "type": "plain_text", + "text": "from.", + "emoji": true + }, + { + "type": "image", + "image_url": "${{ github.event.sender.avatar_url }}", + "alt_text": "" + }, + { + "type": "plain_text", + "text": "${{ env.REVIEWER_NICKNAME }}", + "emoji": true + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "➡️ <@${{ env.ASSIGNEE_SLACK_ID }}>" + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "💡*PR 제목:*\n${{ github.event.pull_request.title }}" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "⚒️ 받은 리뷰 확인하기 ⚒️", + "emoji": true + }, + "value": "PR_LINK", + "url": "${{ github.event.pull_request.html_url }}", + "action_id": "actionId-1" + } + ] + }, + { + "type": "divider" + } + ], + "icon_url": "${{ github.event.sender.avatar_url }}" + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + + pr-approved_alert: + needs: set-slack-thread-if-not-exist-create + if: github.event.review.state == 'APPROVED' + runs-on: ubuntu-latest + steps: + + - name: Set assignee variables + id: set-vars + run: | + ASSIGNEE_LOGIN=${{ github.event.pull_request.assignee.login }} + echo "ASSIGNEE_SLACK_ID=${ASSIGNEE_LOGIN@L}" >> ${GITHUB_ENV} + echo "ASSIGNEE_NICKNAME=${{ env[github.event.pull_request.assignee.login] }}" >> $GITHUB_ENV + REVIEWER_LOGIN=${{ github.event.sender.login }} + echo "REVIEWER_SLACK_ID=${REVIEWER_LOGIN@L}" >> ${GITHUB_ENV} + echo "REVIEWER_NICKNAME=${{ env[github.event.sender.login] }}" >> $GITHUB_ENV + + - name: pr reviewer가 Approve 하면 slack 알림 보냄 + uses: slackapi/slack-github-action@v1.26.0 + with: + channel-id: ${{ secrets.REVIEW_MENTION_CHANNEL_ID }} + payload: | + { + "thread_ts": "${{ needs.set-slack-thread-if-not-exist-create.outputs.SLACK_THREAD_ID }}", + "blocks": [ + { + "type": "divider" + }, + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🏁 PR 승인 🏁", + "emoji": true + } + }, + { + "type": "context", + "elements": [ + { + "type": "plain_text", + "text": "from.", + "emoji": true + }, + { + "type": "image", + "image_url": "${{ github.event.sender.avatar_url }}", + "alt_text": "" + }, + { + "type": "plain_text", + "text": "${{ env.REVIEWER_NICKNAME }}", + "emoji": true + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "<@${{ env.ASSIGNEE_SLACK_ID }}>님! \n리뷰어에게 승인을 받았어요!" + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "🚀 *PR 제목:*\n${{ github.event.pull_request.title }}" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "🕹️ Merge 하러 가기 🕹️", + "emoji": true + }, + "value": "PR_LINK", + "url": "${{ github.event.pull_request.html_url }}", + "action_id": "actionId-1" + } + ] + }, + { + "type": "divider" + } + ], + "icon_url": "${{ github.event.sender.avatar_url }}" + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..6614bc58c --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +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 +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### macOS template +# 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 + +### Exclude .github directory ### +/.github/ + +### Secret files ### +/backend/src/main/resources/application-*.yml +/backend/**/.env + +### log files ### +/backend/log/** +**/*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..658a835de --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM openjdk:17-jdk-slim + +ARG JAR_FILE=/build/libs/cruru-0.0.1-SNAPSHOT.jar +COPY ${JAR_FILE} /cruru.jar + +# 환경 변수를 직접 사용하여 PROFILE 설정 존재하지 않다면 `default` 키워드가 할당됨 +ARG PROFILE +ENV PROFILE_ENV=${PROFILE:-default} + +# Spring profile을 설정하여 애플리케이션 실행 +ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=${PROFILE_ENV}", "/cruru.jar"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 000000000..0f041c577 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,20 @@ +# 서비스 이름 + +### 크루루 (cruru) + +# 크루루는 이런 서비스에요 + +## 주제 + +복잡한 리크루팅 과정을 간소화하는 맞춤형 리크루팅 관리 솔루션 + +## 설명 + +서비스 ‘크루루’는 대학생 연합 동아리를 위한 ATS(지원자 추적 시스템)입니다. 모집 공고 관리, 지원자 목록 관리, 지원 항목 커스터마이징 등을 제공합니다. 해당 서비스를 통해 소규모 리크루팅 프로세스를 효율적으로 관리할 수 있습니다. + +# 💻 개발자 + +| ![아르](https://github.com/user-attachments/assets/2f63c5ab-43bb-417b-92bf-73fd761208a9) | ![러기](https://github.com/user-attachments/assets/f2c8ff64-1a83-466c-851a-ab14cd5530bc)| ![렛서](https://github.com/user-attachments/assets/ff5d9e17-16d6-42fc-8754-c65554313e4e) | ![냥인](https://github.com/user-attachments/assets/4b20cc25-7104-413c-b89e-f22c34a8d0c9) | ![러쉬](https://github.com/user-attachments/assets/86225998-321c-4a11-9c30-2abff1b1c3a1) | ![명오](https://github.com/user-attachments/assets/5316b64b-bc98-446b-b55f-8fa014dbceaa) | ![도비](https://github.com/user-attachments/assets/777f53ac-07cf-43e3-8ebb-f11ae1dc8520) | ![초코칩](https://github.com/user-attachments/assets/dcbd7b64-0ee9-434e-936e-98bf4a36a03d) | +|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:| +| **FE** | **FE** | **FE** | **BE** | **BE** | **BE** | **BE** | **BE** | +|[아르](https://github.com/seongjinme)| [러기](https://github.com/lurgi) | [렛서](https://github.com/llqqssttyy) | [냥인](https://github.com/cutehumanS2) | [러쉬](https://github.com/xogns1514) | [명오](https://github.com/HyungHoKim00) | [도비](https://github.com/Dobby-Kim) | [초코칩](https://github.com/Chocochip101) | diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 000000000..5a9b4a0fe --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,105 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.4' + id 'io.spring.dependency-management' version '1.1.4' + id 'jacoco' + id 'org.asciidoctor.jvm.convert' version '3.3.2' +} + +configurations { + asciidoctorExt +} + +group = 'com' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // Spring + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Security + implementation 'org.springframework.security:spring-security-crypto' + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + + // Database + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.flywaydb:flyway-core:9.22.3' + implementation 'org.flywaydb:flyway-mysql' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Monitoring + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'com.github.loki4j:loki-logback-appender:1.4.2' + + // Email + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured:5.3.1' + + // Docs + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +test { + useJUnitPlatform() + finalizedBy 'jacocoTestReport' + outputs.dir snippetsDir +} + +jacoco { + toolVersion = '0.8.8' +} + +jacocoTestReport { + reports { + xml.required.set(true) + csv.required.set(false) + html.required.set(true) + } + finalizedBy 'jacocoTestCoverageVerification' +} + +jacocoTestCoverageVerification { + violationRules { + rule { + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.80 + } + } + } +} + +asciidoctor { + configurations 'asciidoctorExt' + baseDirFollowsSourceFile() + inputs.dir snippetsDir + dependsOn test +} diff --git a/backend/docker-compose.dev.yml b/backend/docker-compose.dev.yml new file mode 100644 index 000000000..380227fa1 --- /dev/null +++ b/backend/docker-compose.dev.yml @@ -0,0 +1,61 @@ +version: '3.8' + +services: + database-mysql: + container_name: database-container + image: mysql/mysql-server:latest + environment: + MYSQL_DATABASE: ${MYSQL_DB_NAME} + MYSQL_ROOT_HOST: ${MYSQL_ROOT_HOST} + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} + TZ: ${MYSQL_TIME_ZONE} + ports: + - ${DB_PORT} + command: + - '--character-set-server=utf8mb4' + - '--collation-server=utf8mb4_0900_ai_ci' + networks: + cruru_network: + ipv4_address: ${DB_IP_ADDRESS} + + application: + container_name: app_container + platform: linux/arm64 + depends_on: + - database-mysql + restart: always + image: ${DOCKER_REPO_NAME}/cruru:${DOCKER_IMAGE_VERSION_TAG} + ports: + - ${SERVER_BINDING_PORT} + - ${MONITORING_BINDING_PORT} + env_file: + - .env + environment: + PROFILE: dev + TZ: Asia/Seoul + volumes: + - "./log:/log" + networks: + cruru_network: + ipv4_address: ${APP_IP_ADDRESS} + + promtail: + environment: + TZ: Asia/Seoul + MONITORING_INSTANCE_ADDR_LOKI_PORT: ${MONITORING_INSTANCE_ADDR_LOKI_PORT} + container_name: promtail + image: grafana/promtail:latest + volumes: + - ./promtail-config.yml:/etc/promtail/config.yml + - "./log:/log" + command: -config.expand-env=true -config.file=/etc/promtail/config.yml + networks: + cruru_network: + ipv4_address: 172.18.0.4 + +networks: + cruru_network: + driver: bridge + ipam: + config: + - subnet: ${SUBNET} diff --git a/backend/docker-compose.prod.yml b/backend/docker-compose.prod.yml new file mode 100644 index 000000000..363390e87 --- /dev/null +++ b/backend/docker-compose.prod.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + application: + container_name: app_container + platform: linux/arm64 + restart: always + image: ${DOCKER_REPO_NAME}/cruru:${DOCKER_IMAGE_VERSION_TAG} + ports: + - ${SERVER_BINDING_PORT} + - ${MONITORING_BINDING_PORT} + env_file: + - .env + environment: + PROFILE: prod + TZ: Asia/Seoul + volumes: + - "./log:/log" + networks: + cruru_network: + ipv4_address: ${APP_IP_ADDRESS} + + promtail: + environment: + TZ: Asia/Seoul + MONITORING_INSTANCE_ADDR_LOKI_PORT: ${MONITORING_INSTANCE_ADDR_LOKI_PORT} + container_name: promtail + image: grafana/promtail:latest + volumes: + - ./promtail-config.yml:/etc/promtail/config.yml + - "./log:/log" + command: -config.expand-env=true -config.file=/etc/promtail/config.yml + networks: + cruru_network: + ipv4_address: 172.18.0.4 + +networks: + cruru_network: + driver: bridge + ipam: + config: + - subnet: ${SUBNET} diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml new file mode 100644 index 000000000..97d469964 --- /dev/null +++ b/backend/docker-compose.test.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + application: + container_name: app_container + platform: linux/arm64 + restart: always + image: ${DOCKER_REPO_NAME}/cruru:${DOCKER_IMAGE_VERSION_TAG} + ports: + - ${SERVER_BINDING_PORT} + - ${MONITORING_BINDING_PORT} + env_file: + - .env + environment: + PROFILE: test + TZ: Asia/Seoul + volumes: + - "./log:/log" + networks: + cruru_network: + ipv4_address: ${APP_IP_ADDRESS} + + promtail: + environment: + TZ: Asia/Seoul + MONITORING_INSTANCE_ADDR_LOKI_PORT: ${MONITORING_INSTANCE_ADDR_LOKI_PORT} + container_name: promtail + image: grafana/promtail:latest + volumes: + - ./promtail-config.yml:/etc/promtail/config.yml + - "./log:/log" + command: -config.expand-env=true -config.file=/etc/promtail/config.yml + networks: + cruru_network: + ipv4_address: 172.18.0.4 + +networks: + cruru_network: + driver: bridge + ipam: + config: + - subnet: ${SUBNET} diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e6441136f3d4ba8a0da8d277868979cfbc8ad796 GIT binary patch literal 43453 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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
NnX)EH+Nua)3Y(c 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 000000000..25da30dbd --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/promtail-config.yml b/backend/promtail-config.yml new file mode 100644 index 000000000..49870f4df --- /dev/null +++ b/backend/promtail-config.yml @@ -0,0 +1,140 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: "http://${MONITORING_INSTANCE_ADDR_LOKI_PORT}/loki/api/v1/push" + +scrape_configs: + - job_name: error_logs + static_configs: + - targets: + - localhost + labels: + job: error_logs + __path__: /log/error/*.log + pipeline_stages: + - json: + expressions: + timestamp: timestamp + level: level + logger: logger + httpMethod: httpMethod + requestUri: requestUri + statusCode: statusCode + sourceClass: sourceClass + sourceMethod: sourceMethod + exceptionClass: exceptionClass + exceptionMessage: exceptionMessage + module: module + environment: environment + - labels: + level: + logger: + httpMethod: + requestUri: + statusCode: + sourceClass: + sourceMethod: + exceptionClass: + exceptionMessage: + module: + environment: + - timestamp: + source: timestamp + format: 2006-01-02T15:04:05.000 + location: "Asia/Seoul" + + + - job_name: info_logs + static_configs: + - targets: + - localhost + labels: + job: info_logs + __path__: /log/info/*.log + pipeline_stages: + - json: + expressions: + timestamp: timestamp + level: level + logger: logger + httpMethod: httpMethod + requestUri: requestUri + statusCode: statusCode + sourceClass: sourceClass + sourceMethod: sourceMethod + exceptionClass: exceptionClass + exceptionMessage: exceptionMessage + module: module + environment: environment + - labels: + level: + logger: + httpMethod: + requestUri: + statusCode: + sourceClass: + sourceMethod: + exceptionClass: + exceptionMessage: + module: + environment: + - timestamp: + source: timestamp + format: 2006-01-02T15:04:05.000 + location: "Asia/Seoul" + + + - job_name: warn_logs + static_configs: + - targets: + - localhost + labels: + job: warn_logs + __path__: /log/warn/*.log + pipeline_stages: + - json: + expressions: + timestamp: timestamp + level: level + logger: logger + httpMethod: httpMethod + requestUri: requestUri + statusCode: statusCode + sourceClass: sourceClass + sourceMethod: sourceMethod + exceptionClass: exceptionClass + exceptionMessage: exceptionMessage + module: module + environment: environment + - labels: + level: + logger: + httpMethod: + requestUri: + statusCode: + sourceClass: + sourceMethod: + exceptionClass: + exceptionMessage: + module: + environment: + - timestamp: + source: timestamp + format: 2006-01-02T15:04:05.000 + location: "Asia/Seoul" + + - job_name: http_logs + static_configs: + - targets: + - localhost + labels: + job: http_logs + __path__: /var/log/nginx/*.log + pipeline_stages: + - regex: + expression: '^(?P[^\s]+) - - \[(?P[^\]]+)\] "(?P[A-Z]+) (?P[^ ]+) HTTP/[^"]+" (?P\d+) (?P\d+)' diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 000000000..2dd2c45d6 --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1,9 @@ +rootProject.name = 'cruru' + +buildCache { + local { + enabled = true + directory = new File(rootDir, '.gradle/build-cache') + removeUnusedEntriesAfterDays = 7 + } +} diff --git a/backend/src/docs/asciidoc/applicant.adoc b/backend/src/docs/asciidoc/applicant.adoc new file mode 100644 index 000000000..0ffb8d750 --- /dev/null +++ b/backend/src/docs/asciidoc/applicant.adoc @@ -0,0 +1,69 @@ +== 지원자 + +=== 지원자들의 프로세스 일괄 수정 + +==== 성공 + +operation::applicant/move-process[snippets="http-request,path-parameters,request-cookies,request-fields,http-response"] + +==== 실패: 존재하지 않는 프로세스 + +operation::applicant/move-process-fail/process-not-found[snippets="http-request,path-parameters,request-cookies,request-fields,http-response"] + +=== 지원자 기본 정보 조회 + +==== 성공 + +operation::applicant/read-profile[snippets="http-request,path-parameters,request-cookies,http-response,response-fields"] + +==== 실패: 존재하지 않는 지원자 + +operation::applicant/read-profile-fail/applicant-not-found[snippets="http-request,path-parameters,request-cookies,http-response"] + +=== 지원자 상세 정보 조회 + +==== 성공 + +operation::applicant/read-detail-profile[snippets="http-request,path-parameters,request-cookies,http-response,response-fields"] + +==== 실패: 존재하지 않는 지원자 + +operation::applicant/read-detail-profile-fail/applicant-not-found[snippets="http-request,path-parameters,request-cookies,http-response"] + +=== 지원자 불합격 + +==== 성공 + +operation::applicant/reject[snippets="http-request,path-parameters,request-cookies,http-response"] + +==== 실패: 존재하지 않는 지원자 + +operation::applicant/reject-fail/applicant-not-found[snippets="http-request,path-parameters,request-cookies,http-response"] + +==== 실패: 이미 불합격한 지원자 + +operation::applicant/reject-fail/already-rejected[snippets="http-request,path-parameters,request-cookies,http-response"] + +=== 지원자 불합격 해제 + +==== 성공 + +operation::applicant/unreject[snippets="http-request,path-parameters,request-cookies,http-response"] + +==== 실패: 존재하지 않는 지원자 + +operation::applicant/unreject-fail/applicant-not-found[snippets="http-request,path-parameters,request-cookies,http-response"] + +==== 실패: 불합격하지 않은 지원자 + +operation::applicant/unreject-fail/applicant-not-rejected[snippets="http-request,path-parameters,request-cookies,http-response"] + +=== 지원자 정보 변경 + +==== 성공 + +operation::applicant/change-info[snippets="http-request,path-parameters,request-cookies,http-response"] + +==== 실패: 존재하지 않는 지원자 + +operation::applicant/change-info-fail/applicant-not-found[snippets="http-request,path-parameters,request-cookies,http-response"] diff --git a/backend/src/docs/asciidoc/applyform.adoc b/backend/src/docs/asciidoc/applyform.adoc new file mode 100644 index 000000000..6e6000f46 --- /dev/null +++ b/backend/src/docs/asciidoc/applyform.adoc @@ -0,0 +1,59 @@ +== 지원폼 + +=== 지원폼 제출 + +==== 성공 + +operation::applyform/submit[snippets="http-request,path-parameters,request-fields,http-response"] + +==== 실패: 개인정보 활용 거부 + +operation::applicant/submit-fail/reject-personal-data-collection[snippets="http-request,path-parameters,request-fields,http-response"] + +==== 실패: 잘못된 지원자 정보 + +operation::applicant/submit-fail/invalid-applicant-info[snippets="http-request,path-parameters,request-fields,http-response"] + +==== 실패: 잘못된 답변 + +operation::applicant/submit-fail/invalid-answers[snippets="http-request,path-parameters,request-fields,http-response"] + +==== 실패: 대시보드 내에 제출 프로세스 존재하지 않을 경우 + +operation::applicant/submit-fail/no-submit-process[snippets="http-request,path-parameters,request-fields,http-response"] + +==== 실패: 모집 기간을 벗어난 제출 + +operation::applicant/submit-fail/date-out-of-range[snippets="http-request,path-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 지원폼 + +operation::applicant/submit-fail/applyform-not-found[snippets="http-request,path-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 질문 + +operation::applicant/submit-fail/question-not-found[snippets="http-request,path-parameters,request-fields,http-response"] + +==== 실패: 필수 질문에 응답하지 않음 + +operation::applicant/submit-fail/required-not-replied[snippets="http-request,path-parameters,request-fields,http-response"] + +=== 지원폼 조회 + +==== 성공 + +operation::applicant/read-applyform[snippets="http-request,path-parameters,http-response,response-fields"] + +==== 실패: 존재하지 않는 지원폼 + +operation::applicant/read-applyform-fail/applyform-not-found[snippets="http-request,path-parameters,http-response"] + +=== 지원폼 수정 + +==== 성공 + +operation::applicant/update[snippets="http-request,request-cookies,path-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 지원폼 + +operation::applicant/update-fail/applyform-not-found[snippets="http-request,path-parameters,request-fields,http-response"] diff --git a/backend/src/docs/asciidoc/auth.adoc b/backend/src/docs/asciidoc/auth.adoc new file mode 100644 index 000000000..fd75760aa --- /dev/null +++ b/backend/src/docs/asciidoc/auth.adoc @@ -0,0 +1,25 @@ +== 인증 + +=== 로그인 + +==== 성공 + +operation::auth/login[snippets="http-request,request-fields,http-response,response-headers,response-cookies"] + +==== 실패: 잘못된 패스워드 + +operation::auth/login-fail/invalid-password[snippets="http-request,request-fields,http-response"] + +==== 실패: 존재하지 않는 이메일 + +operation::auth/login-fail/email-not-found[snippets="http-request,request-fields,http-response"] + +=== 로그아웃 + +==== 성공 + +operation::auth/logout[snippets="http-request,request-cookies,http-response,response-headers"] + +==== 실패: 존재하지 않는 토큰 + +operation::auth/logout-fail/token-not-found[snippets="http-request,http-response"] diff --git a/backend/src/docs/asciidoc/club.adoc b/backend/src/docs/asciidoc/club.adoc new file mode 100644 index 000000000..b64161834 --- /dev/null +++ b/backend/src/docs/asciidoc/club.adoc @@ -0,0 +1,15 @@ +== 동아리 + +=== 동아리 생성 + +==== 성공 + +operation::club/create[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 사용자 + +operation::club/create-fail/member-not-found[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 조건에 맞지 않는 동아리 이름 + +operation::club/create-fail/invalid-name[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] diff --git a/backend/src/docs/asciidoc/dashboard.adoc b/backend/src/docs/asciidoc/dashboard.adoc new file mode 100644 index 000000000..d6742bdbf --- /dev/null +++ b/backend/src/docs/asciidoc/dashboard.adoc @@ -0,0 +1,21 @@ +== 대시보드 + +=== 대시보드 생성 + +==== 성공 + +operation::dashboard/create[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 적절하지 않은 질문 생성 + +operation::dashboard/create-fail/invalid-question[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 동아리 + +operation::dashboard/create-fail/club-not-found[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +=== 대시보드 조회 + +==== 성공 + +operation::dashboard/read[snippets="http-request,request-cookies,query-parameters,http-response,response-fields"] diff --git a/backend/src/docs/asciidoc/email.adoc b/backend/src/docs/asciidoc/email.adoc new file mode 100644 index 000000000..ea8f7179d --- /dev/null +++ b/backend/src/docs/asciidoc/email.adoc @@ -0,0 +1,15 @@ +== 이메일 + +=== 이메일 발송 및 발송 내역 생성 + +==== 성공 + +operation::email/send[snippets="http-request,request-cookies,request-parts,http-response"] + +==== 실패: 이메일 형식이 올바르지 않은 이메일 형식 + +operation::email/send-fail/invalid-email[snippets="http-request,request-cookies,request-parts,http-response"] + +==== 실패: 존재하지 않는 동아리 + +operation::email/send-fail/club-not-found[snippets="http-request,request-cookies,request-parts,http-response"] diff --git a/backend/src/docs/asciidoc/evaluation.adoc b/backend/src/docs/asciidoc/evaluation.adoc new file mode 100644 index 000000000..0a125b5f0 --- /dev/null +++ b/backend/src/docs/asciidoc/evaluation.adoc @@ -0,0 +1,47 @@ +== 평가 + +=== 평가 생성 + +==== 성공 + +operation::evaluation/create[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 지원자 + +operation::evaluation/create-fail/applicant-not-found[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 프로세스 + +operation::evaluation/create-fail/process-not-found[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 조건에 맞지 않는 평가 점수 + +operation::evaluation/create-fail/invalid-score[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +=== 평가 조회 + +==== 성공 + +operation::evaluation/read[snippets="http-request,request-cookies,query-parameters,http-response,response-fields"] + +==== 실패: 존재하지 않는 지원자 + +operation::evaluation/read-fail/applicant-not-found[snippets="http-request,request-cookies,query-parameters,http-response"] + +==== 실패: 존재하지 않는 프로세스 + +operation::evaluation/read-fail/process-not-found[snippets="http-request,request-cookies,query-parameters,http-response"] + +=== 평가 변경 + +==== 성공 + +operation::evaluation/update[snippets="http-request,request-cookies,path-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 평가 + +operation::evaluation/update-fail/evaluation-not-found[snippets="http-request,request-cookies,path-parameters,http-response"] + +==== 실패: 조건에 맞지 않는 평가 점수 + +operation::evaluation/update-fail/invalid-score[snippets="http-request,request-cookies,path-parameters,request-fields,http-response"] diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..0ab9ada73 --- /dev/null +++ b/backend/src/docs/asciidoc/index.adoc @@ -0,0 +1,18 @@ += 크루루 API 문서 v1.0 +:doctype: book +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:toc-title: API 목록 +:sectnums: + +include::auth.adoc[] +include::member.adoc[] +include::applicant.adoc[] +include::club.adoc[] +include::dashboard.adoc[] +include::process.adoc[] +include::question.adoc[] +include::applyform.adoc[] +include::evaluation.adoc[] +include::email.adoc[] diff --git a/backend/src/docs/asciidoc/member.adoc b/backend/src/docs/asciidoc/member.adoc new file mode 100644 index 000000000..fbd907607 --- /dev/null +++ b/backend/src/docs/asciidoc/member.adoc @@ -0,0 +1,11 @@ +== 사용자 + +=== 사용자 회원가입 + +==== 성공 + +operation::member/signup[snippets="http-request,request-fields,http-response"] + +==== 실패: 유효하지 않은 요청 + +operation::member/signup-fail/invalid-request[snippets="http-request,request-fields,http-response"] diff --git a/backend/src/docs/asciidoc/process.adoc b/backend/src/docs/asciidoc/process.adoc new file mode 100644 index 000000000..75978d872 --- /dev/null +++ b/backend/src/docs/asciidoc/process.adoc @@ -0,0 +1,61 @@ +== 프로세스 + +=== 프로세스 목록 조회 + +==== 성공 + +operation::process/read[snippets="http-request,request-cookies,query-parameters,http-response,response-fields"] + +==== 실패: 존재하지 않는 대시보드 + +operation::process/read-fail/dashboard-not-found[snippets="http-request,request-cookies,query-parameters,http-response"] + +=== 프로세스 생성 + +==== 성공 + +operation::process/create[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 대시보드 + +operation::process/create-fail/dashboard-not-found[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 조건에 맞지 않는 프로세스 이름 + +operation::process/create-fail/invalid-name[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 최대 프로세스 개수 초과 + +operation::process/create-fail/process-count-overed[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +=== 프로세스 수정 + +==== 성공 + +operation::process/update[snippets="http-request,request-cookies,path-parameters,request-fields,http-response,response-fields"] + +==== 실패: 조건에 맞지 않는 프로세스 이름 + +operation::process/update-fail/invalid-name[snippets="http-request,request-cookies,path-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 프로세스 + +operation::process/update-fail/process-not-found[snippets="http-request,request-cookies,path-parameters,request-fields,http-response"] + +=== 프로세스 삭제 + +==== 성공 + +operation::process/delete[snippets="http-request,request-cookies,path-parameters,http-response"] + +==== 실패: 존재하지 않는 프로세스 + +operation::process/delete-fail/process-not-found[snippets="http-request,request-cookies,path-parameters,http-response"] + +==== 실패: 처음 혹은 마지막 프로세스 + +operation::process/delete-fail/process-order-first-or-last[snippets="http-request,request-cookies,path-parameters,http-response"] + +==== 실패: 지원자가 존재하는 프로세스 + +operation::process/delete-fail/process-not-found[snippets="http-request,request-cookies,path-parameters,http-response"] diff --git a/backend/src/docs/asciidoc/question.adoc b/backend/src/docs/asciidoc/question.adoc new file mode 100644 index 000000000..e6896c0e7 --- /dev/null +++ b/backend/src/docs/asciidoc/question.adoc @@ -0,0 +1,11 @@ +== 질문 + +=== 질문 변경 + +==== 성공 + +operation::question/update[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] + +==== 실패: 존재하지 않는 지원폼 + +operation::question/update[snippets="http-request,request-cookies,query-parameters,request-fields,http-response"] diff --git a/backend/src/main/java/com/cruru/BaseEntity.java b/backend/src/main/java/com/cruru/BaseEntity.java new file mode 100644 index 000000000..c342c66bf --- /dev/null +++ b/backend/src/main/java/com/cruru/BaseEntity.java @@ -0,0 +1,21 @@ +package com.cruru; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public class BaseEntity { + + @CreatedDate + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime updatedDate; +} diff --git a/backend/src/main/java/com/cruru/CruruApplication.java b/backend/src/main/java/com/cruru/CruruApplication.java new file mode 100644 index 000000000..469db9e03 --- /dev/null +++ b/backend/src/main/java/com/cruru/CruruApplication.java @@ -0,0 +1,14 @@ +package com.cruru; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class CruruApplication { + + public static void main(String[] args) { + SpringApplication.run(CruruApplication.class, args); + } +} diff --git a/backend/src/main/java/com/cruru/DataLoader.java b/backend/src/main/java/com/cruru/DataLoader.java new file mode 100644 index 000000000..b886b6d13 --- /dev/null +++ b/backend/src/main/java/com/cruru/DataLoader.java @@ -0,0 +1,361 @@ +package com.cruru; + +import static com.cruru.question.domain.QuestionType.LONG_ANSWER; +import static com.cruru.question.domain.QuestionType.MULTIPLE_CHOICE; +import static com.cruru.question.domain.QuestionType.SHORT_ANSWER; +import static com.cruru.question.domain.QuestionType.SINGLE_CHOICE; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applicant.domain.repository.EvaluationRepository; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.club.domain.Club; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.ProcessType; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.question.domain.Answer; +import com.cruru.question.domain.Choice; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.AnswerRepository; +import com.cruru.question.domain.repository.ChoiceRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Component +@Profile("!test") +@RequiredArgsConstructor +public class DataLoader implements ApplicationRunner { + + private final MemberRepository memberRepository; + private final ClubRepository clubRepository; + private final DashboardRepository dashboardRepository; + private final ProcessRepository processRepository; + private final ApplicantRepository applicantRepository; + private final QuestionRepository questionRepository; + private final ChoiceRepository choiceRepository; + private final AnswerRepository answerRepository; + private final EvaluationRepository evaluationRepository; + private final ApplyFormRepository applyFormRepository; + + @Value("${dataloader.enable}") + private boolean enableDataLoader; + + @Override + public void run(ApplicationArguments args) { + if (enableDataLoader) { + runDataLoader(); + } + } + + private void runDataLoader() { + Member member = new Member("member@mail.com", "$2a$10$rG0JsflKdGcORjGFTURYb.npEgtvClK4.3P.EMr/o3SdekrVFxOvG", + "01012345678"); // password 원문: qwer1234 + Member member2 = new Member("member2@mail.com", "$2a$10$rG0JsflKdGcORjGFTURYb.npEgtvClK4.3P.EMr/o3SdekrVFxOvG", + "01012345678"); // password 원문: qwer1234 + + memberRepository.save(member); + memberRepository.save(member2); + + Club club = new Club("우아한테크코스", member); + clubRepository.save(club); + Club club2 = new Club("우아한테크코스", member2); + clubRepository.save(club2); + + Dashboard dashboard = new Dashboard(club); + LocalDateTime startDate = LocalDateTime.of(2020, 10, 6, 15, 0, 0); + LocalDateTime endDate = LocalDateTime.of(2054, 10, 16, 10, 0, 0); + dashboardRepository.save(dashboard); + String description = """ +

2025 신입생 (7기) 선발 일정


  1. 서류접수: 2024년 10월 6일(금) 오후 3시 ~ 10월 16일(월) 오전 10시
  2. 프리코스: 2024년 10월 19일(목) ~ 11월 15일(수)
  3. 1차 합격자 발표: 2024년 12월 11일(월) 오후 3시, 개별 E-mail 통보
  4. 최종 코딩 테스트: 2024년 12월 16일(토)
  5. 최종 합격자 발표: 2024년 12월 27일(수) 오후 3시, 개별 E-mail 통보


2025 신입생 (7기) 교육 기간


  1. 2025년 2월 ~ 11월 (약 10개월)
+ """; + ApplyForm applyForm = new ApplyForm( + "우아한테크코스 2025 백엔드 신입생 모집 ", + description, + startDate, + endDate, + dashboard + ); + applyFormRepository.save(applyForm); + + Process firstProcess = new Process(0, "프리코스", "지원 서류를 확인한다.", ProcessType.APPLY, dashboard); + Process codingTest = new Process(1, "최종 코딩 테스트", "최종 코딩 테스트 전형", ProcessType.EVALUATE, dashboard); + Process lastProcess = new Process(2, "최종 합격", "최종 합격자", ProcessType.APPROVE, dashboard); + processRepository.saveAll(List.of(firstProcess, codingTest, lastProcess)); + + Applicant lurgi = new Applicant(1L, "러기", "lurg@mail.com", "01011111111", firstProcess, false); + Applicant dobby = new Applicant(2L, "도비", "dobb@mail.com", "01022222222", firstProcess, false); + Applicant arrr = new Applicant(3L, "아르", "arrr@mail.com", "01033333333", lastProcess, true); + Applicant chocochip = new Applicant(4L, "초코칩", "choc@mail.com", "01044444444", lastProcess, false); + Applicant myungoh = new Applicant(5L, "명오", "myun@mail.com", "01055555555", lastProcess, false); + Applicant rush = new Applicant(6L, "러시", "rush@mail.com", "01066666666", firstProcess, false); + Applicant nyangin = new Applicant(7L, "냥인", "oddpinkjadeite@gmail.com", "01077777777", firstProcess, false); + Applicant redpanda = new Applicant(8L, "렛서", "pand@mail.com", "01088888888", firstProcess, false); + List applicants = List.of(lurgi, dobby, arrr, chocochip, myungoh, rush, nyangin, redpanda); + applicantRepository.saveAll(applicants); + + Question essayQuestion = questionRepository.save( + new Question(SHORT_ANSWER, "좋아하는 숫자가 무엇인가요?", 1, false, applyForm)); + + Question question1 = questionRepository.save( + new Question(LONG_ANSWER, "효과적인 학습 방식과 경험", 1, false, applyForm) + ); + + Question question2 = questionRepository.save( + new Question(LONG_ANSWER, "성장 중 겪은 실패와 극복", 2, false, applyForm) + ); + + Question question3 = questionRepository.save( + new Question(LONG_ANSWER, "오랜 시간 몰입했던 경험 그리고 도전", 3, false, applyForm) + ); + + Question question4 = questionRepository.save( + new Question(LONG_ANSWER, "오랜 시간 몰입했던 경험 그리고 도전", 4, false, applyForm) + ); + + Question question5 = questionRepository.save( + new Question(MULTIPLE_CHOICE, "지원 경로", 5, false, applyForm) + ); + + Choice homepage = choiceRepository.save(new Choice("우아한테크코스 홈페이지", 1, question5)); + Choice youtube = choiceRepository.save(new Choice("우아한테크코스 유튜브", 2, question5)); + Choice community = choiceRepository.save(new Choice("직무 관련 동아리/커뮤니티", 3, question5)); + Choice socialMedia = choiceRepository.save(new Choice("소셜 미디어", 4, question5)); + + Question question6 = questionRepository.save( + new Question(SINGLE_CHOICE, "모든 문항에 답했는지 확인해주세요. 제출 후에 수정이 불가능합니다.", 6, false, applyForm) + ); + + Choice yes = choiceRepository.save(new Choice("네, 확인했습니다.", 1, question6)); + Choice no = choiceRepository.save(new Choice("다시 확인하겠습니다.", 2, question6)); + + List answers = List.of( + new Answer("서울대학교 컴퓨터공학과 졸업", question1, lurgi), + new Answer("카이스트 소프트웨어학과 재학 중", question1, dobby), + new Answer("부산대학교 전자공학과 졸업", question1, arrr), + new Answer("한양대학교 정보통신공학과 졸업", question1, chocochip), + new Answer("고려대학교 정보보호학과 졸업", question1, myungoh), + new Answer("연세대학교 컴퓨터과학과 재학 중", question1, rush), + new Answer("성균관대학교 소프트웨어학과 졸업", question1, nyangin), + new Answer("이화여자대학교 컴퓨터공학과 졸업", question1, redpanda), + + new Answer( + "저는 효율적인 학습을 위해 주로 플래닝과 시간을 잘 쪼개서 사용하는 방법을 활용합니다. 학습 계획을 세워 목표를 설정하고, 이를 달성하기 위해 매일 꾸준히 공부합니다. 이 방식은 프로그래밍 학습에도 큰 도움이 되고 있습니다.", + question1, + lurgi + ), + new Answer( + "혼자 학습하는 것보다 그룹 스터디를 통해 다른 사람들과 함께 공부하는 것이 저에게는 더 효과적이었습니다. 다양한 시각에서 문제를 바라보고 해결하는 데 큰 도움이 되었습니다.", + question1, + dobby + ), + new Answer( + "이해가 안 되는 부분은 여러 번 반복해서 공부하는 방식이 저에게 유효했습니다. 프로그래밍에서도 어려운 부분이 있을 때 계속 반복해서 코드를 작성하고 문제를 해결하면서 실력을 키워가고 있습니다.", + question1, + arrr + ), + new Answer( + "온라인 강의와 강의 노트를 병행하는 학습 방식이 저에게는 가장 효과적이었습니다. 이를 통해 개념을 더 명확히 이해하고 실전에 적용할 수 있었습니다.", + question1, + chocochip + ), + new Answer( + "프로젝트 기반 학습이 저에게는 가장 효과적이었습니다. 실제로 프로젝트를 수행하면서 배우는 것이 이론 학습보다 훨씬 더 잘 이해되고 기억에 오래 남았습니다.", + question1, + myungoh + ), + new Answer( + "퀴즈나 테스트를 통해 학습 내용을 점검하는 방식이 저에게는 유효했습니다. 이를 통해 어떤 부분이 약한지 파악하고, 그 부분을 집중적으로 공부할 수 있었습니다.", + question1, + rush + ), + new Answer( + "실제 문제를 풀어보면서 학습하는 것이 저에게는 가장 효과적이었습니다. 이를 통해 학습한 이론을 실전에 적용하고, 문제 해결 능력을 키울 수 있었습니다.", + question1, + nyangin + ), + new Answer( + "문제를 해결할 때마다 그 과정을 기록하고 복습하는 방식이 저에게는 가장 효과적이었습니다. 이를 통해 비슷한 문제를 다시 만났을 때 쉽게 해결할 수 있었습니다.", + question1, + redpanda + ), + + new Answer( + "첫 번째 프로그래밍 프로젝트에서 많은 어려움을 겪었지만, 끈기 있게 문제를 해결하고 프로젝트를 완성했습니다. 이 경험을 통해 문제 해결 능력을 키웠고, 현재도 어려움이 닥쳤을 때 포기하지 않고 해결하는 데 큰 도움이 되고 있습니다.", + question2, + lurgi + ), + new Answer( + "팀 프로젝트에서 협업하는 과정에서 많은 어려움을 겪었지만, 팀원들과의 소통과 협력을 통해 문제를 해결했습니다. 이 경험을 통해 협업의 중요성을 깨달았고, 현재의 학습 과정에도 큰 영향을 미치고 있습니다.", + question2, + dobby + ), + new Answer( + "학습 중 여러 번 실패를 경험했지만, 그때마다 새로운 방법을 시도하며 문제를 해결했습니다. 이 경험은 저의 학습 방식에 큰 변화를 주었고, 현재도 다양한 시도를 통해 문제를 해결하고 있습니다.", + question2, + arrr + ), + new Answer( + "초기에는 프로그래밍 언어를 배우는 데 많은 어려움을 겪었지만, 꾸준히 학습하고 연습하면서 문제를 해결했습니다. 이 경험은 저에게 인내심과 꾸준함의 중요성을 일깨워주었고, 현재의 학습 방식에도 영향을 미치고 있습니다.", + question2, + chocochip + ), + new Answer( + "프로젝트 진행 중 여러 차례 문제에 부딪혔지만, 그때마다 팀원들과 함께 해결책을 모색하며 문제를 해결했습니다. 이 경험은 협업의 중요성을 깨닫게 했고, 현재의 학습 과정에도 큰 영향을 미치고 있습니다.", + question2, + myungoh + ), + new Answer( + "코딩 테스트에서 여러 번 실패를 경험했지만, 그때마다 부족한 부분을 보완하며 재도전했습니다. 이 경험은 저에게 끈기의 중요성을 일깨워주었고, 현재의 학습 방식에도 큰 영향을 미치고 있습니다.", + question2, + rush + ), + new Answer( + "프로그래밍 프로젝트에서 발생한 문제를 해결하기 위해 많은 노력을 기울였고, 결국 문제를 해결했습니다. 이 경험은 저에게 문제 해결 능력을 키워주었고, 현재의 학습 방식에도 큰 영향을 미치고 있습니다.", + question2, + nyangin + ), + new Answer( + "협업 프로젝트에서 발생한 갈등을 해결하기 위해 많은 노력을 기울였고, 결국 문제를 해결했습니다. 이 경험은 저에게 협력의 중요성을 깨닫게 했고, 현재의 학습 방식에도 큰 영향을 미치고 있습니다.", + question2, + redpanda + ), + + new Answer( + "대학생 때, 인공지능 관련 프로젝트에 몰입하여 6개월 동안 연구와 개발에 매진했습니다. 이 과정에서 많은 도전을 마주했지만, 최종적으로 프로젝트를 성공적으로 마무리할 수 있었습니다. 이를 통해 문제 해결 능력과 인내심을 배울 수 있었습니다.", + question3, + lurgi + ), + new Answer( + "개인적으로 웹 애플리케이션을 개발하면서 오랜 시간 몰입했습니다. 이 과정에서 여러 가지 문제를 해결해야 했지만, 이를 통해 개발 능력과 문제 해결 능력을 키울 수 있었습니다.", + question3, + dobby + ), + new Answer( + "오픈 소스 프로젝트에 참여하여 오랜 시간 몰입했습니다. 이 과정에서 많은 도전을 마주했지만, 결국 프로젝트를 성공적으로 완료할 수 있었습니다. 이를 통해 협업 능력과 문제 해결 능력을 키울 수 있었습니다.", + question3, + arrr + ), + new Answer( + "대학 졸업 프로젝트에 몰입하여 1년 동안 연구와 개발에 매진했습니다. 이 과정에서 많은 도전을 마주했지만, 최종적으로 프로젝트를 성공적으로 마무리할 수 있었습니다. 이를 통해 문제 해결 능력과 인내심을 배울 수 있었습니다.", + question3, + chocochip + ), + new Answer( + "프로그래밍 경진대회에 참여하여 오랜 시간 몰입했습니다. 이 과정에서 여러 가지 문제를 해결해야 했지만, 이를 통해 개발 능력과 문제 해결 능력을 키울 수 있었습니다.", + question3, + myungoh + ), + new Answer( + "프리랜서 개발자로 일하면서 오랜 시간 몰입했습니다. 이 과정에서 많은 도전을 마주했지만, 이를 통해 문제 해결 능력과 인내심을 배울 수 있었습니다.", + question3, + rush + ), + new Answer( + "개인적으로 블로그를 운영하면서 오랜 시간 몰입했습니다. 이 과정에서 많은 도전을 마주했지만, 이를 통해 글쓰기 능력과 문제 해결 능력을 키울 수 있었습니다.", + question3, + nyangin + ), + new Answer( + "대학 동아리 활동을 통해 오랜 시간 몰입했습니다. 이 과정에서 많은 도전을 마주했지만, 이를 통해 협업 능력과 문제 해결 능력을 키울 수 있었습니다.", + question3, + redpanda + ), + + new Answer( + "저는 존경받는 프로그래머가 되어 여러 사람에게 영감을 주고 싶습니다. 이를 위해 현재 매일 꾸준히 공부하고 있으며, 다양한 프로젝트에 참여하여 실력을 쌓고 있습니다. 만약 우아한테크코스가 없다면, 온라인 강의와 커뮤니티 활동을 통해 지속적으로 성장할 것입니다.", + question4, + lurgi + ), + new Answer( + "저는 문제 해결 능력이 뛰어난 프로그래머가 되고 싶습니다. 이를 위해 현재 다양한 문제를 풀고 있으며, 코드 리뷰를 통해 실력을 향상시키고 있습니다. 만약 우아한테크코스가 없다면, 독학과 스터디 그룹을 통해 지속적으로 성장할 것입니다.", + question4, + dobby + ), + new Answer( + "저는 협업 능력이 뛰어난 프로그래머가 되고 싶습니다. 이를 위해 현재 팀 프로젝트에 참여하고 있으며, 다양한 협업 도구를 익히고 있습니다. 만약 우아한테크코스가 없다면, 오픈 소스 프로젝트에 참여하여 지속적으로 성장할 것입니다.", + question4, + arrr + ), + new Answer( + "저는 창의적인 프로그래머가 되고 싶습니다. 이를 위해 현재 새로운 아이디어를 시도해 보고 있으며, 다양한 기술을 익히고 있습니다. 만약 우아한테크코스가 없다면, 개인 프로젝트를 통해 지속적으로 성장할 것입니다.", + question4, + chocochip + ), + new Answer( + "저는 효율적인 프로그래머가 되고 싶습니다. 이를 위해 현재 코딩 실력을 향상시키기 위해 노력하고 있으며, 다양한 도구를 익히고 있습니다. 만약 우아한테크코스가 없다면, 독학과 실무 경험을 통해 지속적으로 성장할 것입니다.", + question4, + myungoh + ), + new Answer( + "저는 커뮤니케이션 능력이 뛰어난 프로그래머가 되고 싶습니다. 이를 위해 현재 다양한 사람들과 소통하며 협업하는 방법을 익히고 있습니다. 만약 우아한테크코스가 없다면, 커뮤니티 활동을 통해 지속적으로 성장할 것입니다.", + question4, + rush + ), + new Answer( + "저는 문제 해결 능력이 뛰어난 프로그래머가 되고 싶습니다. 이를 위해 현재 다양한 문제를 풀고 있으며, 코드 리뷰를 통해 실력을 향상시키고 있습니다. 만약 우아한테크코스가 없다면, 독학과 스터디 그룹을 통해 지속적으로 성장할 것입니다.", + question4, + nyangin + ), + new Answer( + "저는 팀워크를 잘하는 프로그래머가 되고 싶습니다. 이를 위해 현재 팀 프로젝트에 참여하고 있으며, 다양한 협업 도구를 익히고 있습니다. 만약 우아한테크코스가 없다면, 오픈 소스 프로젝트에 참여하여 지속적으로 성장할 것입니다.", + question4, + redpanda + ), + + new Answer(homepage.getContent(), question5, lurgi), + new Answer(homepage.getContent(), question5, dobby), + new Answer(youtube.getContent(), question5, arrr), + new Answer(youtube.getContent(), question5, chocochip), + new Answer(socialMedia.getContent(), question5, myungoh), + new Answer(socialMedia.getContent(), question5, rush), + new Answer(community.getContent(), question5, nyangin), + new Answer(community.getContent(), question5, redpanda), + + new Answer(yes.getContent(), question6, lurgi), + new Answer(yes.getContent(), question6, dobby), + new Answer(yes.getContent(), question6, arrr), + new Answer(yes.getContent(), question6, chocochip), + new Answer(yes.getContent(), question6, myungoh), + new Answer(yes.getContent(), question6, rush), + new Answer(yes.getContent(), question6, nyangin), + new Answer(yes.getContent(), question6, redpanda) + ); + answerRepository.saveAll(answers); + + List evaluations = List.of( + new Evaluation(5, "우수한 실력", firstProcess, lurgi), + new Evaluation(4, "좋은 잠재력", codingTest, lurgi), + new Evaluation(3, "노력 필요", firstProcess, dobby), + new Evaluation(5, "매우 긍정적", codingTest, dobby), + new Evaluation(3, "성장 가능성", firstProcess, arrr), + new Evaluation(4, "기본기 탄탄", codingTest, arrr), + new Evaluation(4, "뛰어난 이해력", firstProcess, chocochip), + new Evaluation(5, "매우 뛰어남", codingTest, chocochip), + new Evaluation(2, "열정적", firstProcess, myungoh), + new Evaluation(1, "개선 필요", codingTest, myungoh), + new Evaluation(5, "빠른 학습 능력", firstProcess, rush), + new Evaluation(1, "-> 불합격", codingTest, rush), + new Evaluation(4, "꼼꼼함", firstProcess, nyangin), + new Evaluation(4, "전과 동일", codingTest, nyangin), + new Evaluation(3, "예술적 감각", firstProcess, redpanda), + new Evaluation(4, "좋은 평가", codingTest, redpanda) + ); + evaluationRepository.saveAll(evaluations); + } +} diff --git a/backend/src/main/java/com/cruru/HomeController.java b/backend/src/main/java/com/cruru/HomeController.java new file mode 100644 index 000000000..e708f0ef6 --- /dev/null +++ b/backend/src/main/java/com/cruru/HomeController.java @@ -0,0 +1,16 @@ +package com.cruru; + +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 +@RequestMapping("/") +public class HomeController { + + @GetMapping + public ResponseEntity home() { + return ResponseEntity.ok("Welcome to cruru!"); + } +} diff --git a/backend/src/main/java/com/cruru/advice/ConflictException.java b/backend/src/main/java/com/cruru/advice/ConflictException.java new file mode 100644 index 000000000..d30afb9c8 --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/ConflictException.java @@ -0,0 +1,12 @@ +package com.cruru.advice; + +import org.springframework.http.HttpStatus; + +public class ConflictException extends CruruCustomException { + + private static final HttpStatus STATUS = HttpStatus.CONFLICT; + + public ConflictException(String message) { + super(message, STATUS); + } +} diff --git a/backend/src/main/java/com/cruru/advice/CruruCustomException.java b/backend/src/main/java/com/cruru/advice/CruruCustomException.java new file mode 100644 index 000000000..f983645fd --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/CruruCustomException.java @@ -0,0 +1,19 @@ +package com.cruru.advice; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class CruruCustomException extends RuntimeException { + + private final HttpStatus status; + + public CruruCustomException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public String statusCode() { + return status.toString(); + } +} diff --git a/backend/src/main/java/com/cruru/advice/ForbiddenException.java b/backend/src/main/java/com/cruru/advice/ForbiddenException.java new file mode 100644 index 000000000..90a5cfffc --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/ForbiddenException.java @@ -0,0 +1,13 @@ +package com.cruru.advice; + +import org.springframework.http.HttpStatus; + +public class ForbiddenException extends CruruCustomException { + + private static final String MESSAGE = "접근 권한이 없습니다"; + private static final HttpStatus STATUS = HttpStatus.FORBIDDEN; + + public ForbiddenException() { + super(MESSAGE, STATUS); + } +} diff --git a/backend/src/main/java/com/cruru/advice/GlobalExceptionHandler.java b/backend/src/main/java/com/cruru/advice/GlobalExceptionHandler.java new file mode 100644 index 000000000..b9c0c8edf --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/GlobalExceptionHandler.java @@ -0,0 +1,76 @@ +package com.cruru.advice; + +import com.cruru.global.util.ExceptionLogger; +import jakarta.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.context.request.WebRequest; + +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final UncatchedExceptionHandler handler; + + @ExceptionHandler(CruruCustomException.class) + public ResponseEntity handleCustomException(CruruCustomException e) { + HttpServletRequest request = getCurrentHttpRequest(); + ExceptionLogger.info(request, e); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(e.getStatus(), e.getMessage()); + return ResponseEntity.of(problemDetail).build(); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpectedException(Exception e, WebRequest request) { + ProblemDetail problemDetail = handleException(e, request); + HttpStatus statusCode = HttpStatus.valueOf(problemDetail.getStatus()); + if (statusCode.is5xxServerError()) { + ExceptionLogger.error(problemDetail); + } else { + ExceptionLogger.warn(problemDetail); + } + ProblemDetail detailsToSend = ProblemDetail.forStatus(statusCode); + return ResponseEntity.of(detailsToSend).build(); + } + + private ProblemDetail handleException(Exception e, WebRequest request) { + try { + ProblemDetail problemDetail = (ProblemDetail) Objects.requireNonNull(handler.handleException(e, request)) + .getBody(); + problemDetail.setProperties(setDetails(getCurrentHttpRequest(), e, HttpStatus.INTERNAL_SERVER_ERROR)); + return problemDetail; + } catch (Exception ex) { + return ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + "예기치 못한 오류가 발생하였습니다." + ); + } + } + + private HttpServletRequest getCurrentHttpRequest() { + return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest(); + } + + private static Map setDetails(HttpServletRequest request, Exception exception, HttpStatus status) { + StackTraceElement origin = exception.getStackTrace()[0]; + Map map = new HashMap<>(); + map.put("httpMethod", request.getMethod()); + map.put("requestUri", request.getRequestURI()); + map.put("statusCode", status.toString()); + map.put("sourceClass", origin.getClassName()); + map.put("sourceMethod", origin.getMethodName()); + map.put("exceptionClass", exception.getClass().getSimpleName()); + map.put("exceptionMessage", exception.getMessage()); + return map; + } +} diff --git a/backend/src/main/java/com/cruru/advice/InternalServerException.java b/backend/src/main/java/com/cruru/advice/InternalServerException.java new file mode 100644 index 000000000..3543585da --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/InternalServerException.java @@ -0,0 +1,17 @@ +package com.cruru.advice; + +import org.springframework.http.HttpStatus; + +public class InternalServerException extends CruruCustomException { + + private static final String TEXT = "서버 내부에 오류가 발생했습니다."; + private static final HttpStatus STATUS = HttpStatus.INTERNAL_SERVER_ERROR; + + public InternalServerException() { + super(TEXT, STATUS); + } + + public InternalServerException(String text) { + super(text, STATUS); + } +} diff --git a/backend/src/main/java/com/cruru/advice/NotFoundException.java b/backend/src/main/java/com/cruru/advice/NotFoundException.java new file mode 100644 index 000000000..0007eb7c8 --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/NotFoundException.java @@ -0,0 +1,13 @@ +package com.cruru.advice; + +import org.springframework.http.HttpStatus; + +public class NotFoundException extends CruruCustomException { + + private static final String MESSAGE = "존재하지 않는 %s입니다."; + private static final HttpStatus STATUS = HttpStatus.NOT_FOUND; + + public NotFoundException(String target) { + super(String.format(MESSAGE, target), STATUS); + } +} diff --git a/backend/src/main/java/com/cruru/advice/UnauthorizedException.java b/backend/src/main/java/com/cruru/advice/UnauthorizedException.java new file mode 100644 index 000000000..dd5feab15 --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/UnauthorizedException.java @@ -0,0 +1,12 @@ +package com.cruru.advice; + +import org.springframework.http.HttpStatus; + +public class UnauthorizedException extends CruruCustomException { + + private static final HttpStatus STATUS = HttpStatus.UNAUTHORIZED; + + public UnauthorizedException(String message) { + super(message, STATUS); + } +} diff --git a/backend/src/main/java/com/cruru/advice/UncatchedExceptionHandler.java b/backend/src/main/java/com/cruru/advice/UncatchedExceptionHandler.java new file mode 100644 index 000000000..a667f5206 --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/UncatchedExceptionHandler.java @@ -0,0 +1,36 @@ +package com.cruru.advice; + +import java.util.HashMap; +import java.util.Map; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@Component +public class UncatchedExceptionHandler extends ResponseEntityExceptionHandler { + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + Map validation = new HashMap<>(); + for (FieldError fieldError : e.getFieldErrors()) { + validation.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, + validation.values().toString() + ); + return ResponseEntity.of(problemDetail).build(); + } +} diff --git a/backend/src/main/java/com/cruru/advice/badrequest/BadRequestException.java b/backend/src/main/java/com/cruru/advice/badrequest/BadRequestException.java new file mode 100644 index 000000000..a0333cded --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/badrequest/BadRequestException.java @@ -0,0 +1,13 @@ +package com.cruru.advice.badrequest; + +import com.cruru.advice.CruruCustomException; +import org.springframework.http.HttpStatus; + +public class BadRequestException extends CruruCustomException { + + private static final HttpStatus STATUS = HttpStatus.BAD_REQUEST; + + public BadRequestException(String message) { + super(message, STATUS); + } +} diff --git a/backend/src/main/java/com/cruru/advice/badrequest/TextBlankException.java b/backend/src/main/java/com/cruru/advice/badrequest/TextBlankException.java new file mode 100644 index 000000000..a96caabbd --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/badrequest/TextBlankException.java @@ -0,0 +1,10 @@ +package com.cruru.advice.badrequest; + +public class TextBlankException extends BadRequestException { + + private static final String MESSAGE = "%s이(가) 공백입니다."; + + public TextBlankException(String text) { + super(String.format(MESSAGE, text)); + } +} diff --git a/backend/src/main/java/com/cruru/advice/badrequest/TextCharacterException.java b/backend/src/main/java/com/cruru/advice/badrequest/TextCharacterException.java new file mode 100644 index 000000000..019b71818 --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/badrequest/TextCharacterException.java @@ -0,0 +1,10 @@ +package com.cruru.advice.badrequest; + +public class TextCharacterException extends BadRequestException { + + private static final String MESSAGE = "%s에 포함될 수 없는 글자가 존재합니다: %s."; + + public TextCharacterException(String text, String invalidText) { + super(String.format(MESSAGE, text, invalidText)); + } +} diff --git a/backend/src/main/java/com/cruru/advice/badrequest/TextLengthException.java b/backend/src/main/java/com/cruru/advice/badrequest/TextLengthException.java new file mode 100644 index 000000000..f7040fa8f --- /dev/null +++ b/backend/src/main/java/com/cruru/advice/badrequest/TextLengthException.java @@ -0,0 +1,15 @@ +package com.cruru.advice.badrequest; + +public class TextLengthException extends BadRequestException { + + private static final String MESSAGE_NOTICE_MAX = "%s은(는) 최대 %d자입니다. 현재 글자수: %d"; + private static final String MESSAGE_NOTICE_MIN_MAX = "%s은(는) 최소 %d, 최대 %d자입니다. 현재 글자수: %d"; + + public TextLengthException(String text, int maxLength, int currentLength) { + super(String.format(MESSAGE_NOTICE_MAX, text, currentLength, maxLength)); + } + + public TextLengthException(String text, int minLength, int maxLength, int currentLength) { + super(String.format(MESSAGE_NOTICE_MIN_MAX, text, minLength, maxLength, currentLength)); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/ApplicantController.java b/backend/src/main/java/com/cruru/applicant/controller/ApplicantController.java new file mode 100644 index 000000000..09e45a41f --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/ApplicantController.java @@ -0,0 +1,82 @@ +package com.cruru.applicant.controller; + +import com.cruru.applicant.controller.request.ApplicantMoveRequest; +import com.cruru.applicant.controller.request.ApplicantUpdateRequest; +import com.cruru.applicant.controller.response.ApplicantAnswerResponses; +import com.cruru.applicant.controller.response.ApplicantBasicResponse; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.facade.ApplicantFacade; +import com.cruru.auth.annotation.RequireAuthCheck; +import com.cruru.global.LoginProfile; +import com.cruru.process.domain.Process; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/applicants") +@RequiredArgsConstructor +public class ApplicantController { + + private final ApplicantFacade applicantFacade; + + @RequireAuthCheck(targetId = "applicantId", targetDomain = Applicant.class) + @GetMapping("/{applicantId}") + public ResponseEntity read(@PathVariable Long applicantId, LoginProfile loginProfile) { + ApplicantBasicResponse applicantResponse = applicantFacade.readBasicById(applicantId); + return ResponseEntity.ok().body(applicantResponse); + } + + @RequireAuthCheck(targetId = "applicantId", targetDomain = Applicant.class) + @GetMapping("/{applicantId}/detail") + public ResponseEntity readDetail( + @PathVariable Long applicantId, + LoginProfile loginProfile + ) { + ApplicantAnswerResponses applicantAnswerResponses = applicantFacade.readDetailById(applicantId); + return ResponseEntity.ok().body(applicantAnswerResponses); + } + + @RequireAuthCheck(targetId = "applicantId", targetDomain = Applicant.class) + @PatchMapping("/{applicantId}") + public ResponseEntity updateInformation( + @PathVariable Long applicantId, + @RequestBody @Valid ApplicantUpdateRequest request, + LoginProfile loginProfile + ) { + applicantFacade.updateApplicantInformation(applicantId, request); + return ResponseEntity.ok().build(); + } + + @RequireAuthCheck(targetId = "processId", targetDomain = Process.class) + @PutMapping("/move-process/{processId}") + public ResponseEntity updateProcess( + @PathVariable Long processId, + @RequestBody @Valid ApplicantMoveRequest moveRequest, + LoginProfile loginProfile + ) { + applicantFacade.updateApplicantProcess(processId, moveRequest); + return ResponseEntity.ok().build(); + } + + @RequireAuthCheck(targetId = "applicantId", targetDomain = Applicant.class) + @PatchMapping("/{applicantId}/reject") + public ResponseEntity reject(@PathVariable Long applicantId, LoginProfile loginProfile) { + applicantFacade.reject(applicantId); + return ResponseEntity.ok().build(); + } + + @RequireAuthCheck(targetId = "applicantId", targetDomain = Applicant.class) + @PatchMapping("/{applicantId}/unreject") + public ResponseEntity unreject(@PathVariable Long applicantId, LoginProfile loginProfile) { + applicantFacade.unreject(applicantId); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/EvaluationController.java b/backend/src/main/java/com/cruru/applicant/controller/EvaluationController.java new file mode 100644 index 000000000..3b687252e --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/EvaluationController.java @@ -0,0 +1,65 @@ +package com.cruru.applicant.controller; + +import com.cruru.applicant.controller.request.EvaluationCreateRequest; +import com.cruru.applicant.controller.request.EvaluationUpdateRequest; +import com.cruru.applicant.controller.response.EvaluationResponses; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.applicant.facade.EvaluationFacade; +import com.cruru.auth.annotation.RequireAuthCheck; +import com.cruru.global.LoginProfile; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/evaluations") +@RequiredArgsConstructor +public class EvaluationController { + + private final EvaluationFacade evaluationFacade; + + @RequireAuthCheck(targetId = "applicantId", targetDomain = Applicant.class) + @PostMapping + public ResponseEntity create( + @RequestBody @Valid EvaluationCreateRequest request, + @RequestParam(name = "processId") Long processId, + @RequestParam(name = "applicantId") Long applicantId, + LoginProfile loginProfile + ) { + evaluationFacade.create(request, processId, applicantId); + String url = String.format("/v1/evaluations?processId=%d&applicantId=%d", processId, applicantId); + return ResponseEntity.created(URI.create(url)).build(); + } + + @RequireAuthCheck(targetId = "applicantId", targetDomain = Applicant.class) + @GetMapping + public ResponseEntity read( + @RequestParam(name = "processId") Long processId, + @RequestParam(name = "applicantId") Long applicantId, + LoginProfile loginProfile + ) { + EvaluationResponses response = evaluationFacade.readEvaluationsOfApplicantInProcess(processId, applicantId); + return ResponseEntity.ok(response); + } + + @RequireAuthCheck(targetId = "evaluationId", targetDomain = Evaluation.class) + @PatchMapping("/{evaluationId}") + public ResponseEntity update( + @RequestBody @Valid EvaluationUpdateRequest request, + @PathVariable Long evaluationId, + LoginProfile loginProfile + ) { + evaluationFacade.updateSingleEvaluation(request, evaluationId); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/request/ApplicantCreateRequest.java b/backend/src/main/java/com/cruru/applicant/controller/request/ApplicantCreateRequest.java new file mode 100644 index 000000000..c648e3042 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/request/ApplicantCreateRequest.java @@ -0,0 +1,18 @@ +package com.cruru.applicant.controller.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record ApplicantCreateRequest( + @NotBlank(message = "이름은 필수 값입니다.") + String name, + + @NotBlank(message = "이메일은 필수 값입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "전화번호는 필수 값입니다.") + String phone +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/request/ApplicantMoveRequest.java b/backend/src/main/java/com/cruru/applicant/controller/request/ApplicantMoveRequest.java new file mode 100644 index 000000000..98cc53154 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/request/ApplicantMoveRequest.java @@ -0,0 +1,11 @@ +package com.cruru.applicant.controller.request; + +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record ApplicantMoveRequest( + @NotNull(message = "지원자 목록은 필수 값입니다.") + List applicantIds +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/request/ApplicantUpdateRequest.java b/backend/src/main/java/com/cruru/applicant/controller/request/ApplicantUpdateRequest.java new file mode 100644 index 000000000..de87b2394 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/request/ApplicantUpdateRequest.java @@ -0,0 +1,16 @@ +package com.cruru.applicant.controller.request; + +import jakarta.validation.constraints.NotBlank; + +public record ApplicantUpdateRequest( + @NotBlank(message = "이름은 필수 값입니다.") + String name, + + @NotBlank(message = "이메일은 필수 값입니다.") + String email, + + @NotBlank(message = "전화번호는 필수 값입니다.") + String phone +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/request/EvaluationCreateRequest.java b/backend/src/main/java/com/cruru/applicant/controller/request/EvaluationCreateRequest.java new file mode 100644 index 000000000..eae389fb8 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/request/EvaluationCreateRequest.java @@ -0,0 +1,15 @@ +package com.cruru.applicant.controller.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record EvaluationCreateRequest( + @NotNull(message = "평가 점수는 필수 값입니다.") + @Positive(message = "평가 점수는 1 이상 5 이하의 정수입니다.") + Integer score, + + @NotNull(message = "평가 내용은 필수 값입니다.") + String content +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/request/EvaluationUpdateRequest.java b/backend/src/main/java/com/cruru/applicant/controller/request/EvaluationUpdateRequest.java new file mode 100644 index 000000000..bdc7d9531 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/request/EvaluationUpdateRequest.java @@ -0,0 +1,15 @@ +package com.cruru.applicant.controller.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record EvaluationUpdateRequest( + @NotNull(message = "평가 점수는 필수 값입니다.") + @Positive(message = "평가 점수는 1 이상 5 이하의 정수입니다.") + Integer score, + + @NotNull(message = "평가 내용은 필수 값입니다.") + String content +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantAnswerResponses.java b/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantAnswerResponses.java new file mode 100644 index 000000000..c72f61b0a --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantAnswerResponses.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.controller.response; + +import com.cruru.question.controller.response.AnswerResponse; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record ApplicantAnswerResponses( + @JsonProperty("details") + List answerResponses +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantBasicResponse.java b/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantBasicResponse.java new file mode 100644 index 000000000..4dcb46e2c --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantBasicResponse.java @@ -0,0 +1,14 @@ +package com.cruru.applicant.controller.response; + +import com.cruru.process.controller.response.ProcessSimpleResponse; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record ApplicantBasicResponse( + @JsonProperty("applicant") + ApplicantResponse applicantResponse, + + @JsonProperty("process") + ProcessSimpleResponse processResponse +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantCardResponse.java b/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantCardResponse.java new file mode 100644 index 000000000..aa32f4d6b --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantCardResponse.java @@ -0,0 +1,22 @@ +package com.cruru.applicant.controller.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDateTime; + +public record ApplicantCardResponse( + @JsonProperty("applicantId") + long id, + + @JsonProperty("applicantName") + String name, + + LocalDateTime createdAt, + + Boolean isRejected, + + int evaluationCount, + + double averageScore +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantResponse.java b/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantResponse.java new file mode 100644 index 000000000..e2c021ca9 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/response/ApplicantResponse.java @@ -0,0 +1,19 @@ +package com.cruru.applicant.controller.response; + +import java.time.LocalDateTime; + +public record ApplicantResponse( + long id, + + String name, + + String email, + + String phone, + + boolean isRejected, + + LocalDateTime createdAt +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/response/EvaluationResponse.java b/backend/src/main/java/com/cruru/applicant/controller/response/EvaluationResponse.java new file mode 100644 index 000000000..296aac660 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/response/EvaluationResponse.java @@ -0,0 +1,18 @@ +package com.cruru.applicant.controller.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonFormat.Shape; +import java.time.LocalDateTime; + +public record EvaluationResponse( + long evaluationId, + + int score, + + String content, + + @JsonFormat(shape = Shape.STRING) + LocalDateTime createdDate +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/controller/response/EvaluationResponses.java b/backend/src/main/java/com/cruru/applicant/controller/response/EvaluationResponses.java new file mode 100644 index 000000000..4ea2f13f1 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/controller/response/EvaluationResponses.java @@ -0,0 +1,11 @@ +package com.cruru.applicant.controller.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record EvaluationResponses( + @JsonProperty("evaluations") + List evaluationsResponse +) { + +} diff --git a/backend/src/main/java/com/cruru/applicant/domain/Applicant.java b/backend/src/main/java/com/cruru/applicant/domain/Applicant.java new file mode 100644 index 000000000..f01faec11 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/domain/Applicant.java @@ -0,0 +1,162 @@ +package com.cruru.applicant.domain; + +import com.cruru.BaseEntity; +import com.cruru.applicant.exception.badrequest.ApplicantIllegalPhoneNumberException; +import com.cruru.applicant.exception.badrequest.ApplicantNameBlankException; +import com.cruru.applicant.exception.badrequest.ApplicantNameCharacterException; +import com.cruru.applicant.exception.badrequest.ApplicantNameLengthException; +import com.cruru.auth.util.SecureResource; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.member.domain.Member; +import com.cruru.process.domain.Process; +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.util.Objects; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Applicant extends BaseEntity implements SecureResource { + + private static final int MAX_NAME_LENGTH = 32; + private static final Pattern NAME_PATTERN = Pattern.compile("^[가-힣a-zA-Z\\s-]+$"); + private static final Pattern PHONE_PATTERN = Pattern.compile( + "^(010)\\d{3,4}\\d{4}$|^(02|0[3-6][1-5])\\d{3,4}\\d{4}$"); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "applicant_id") + private Long id; + + private String name; + + private String email; + + private String phone; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "process_id") + private Process process; + + @Column(name = "is_rejected") + private boolean isRejected; + + public Applicant(String name, String email, String phone, Process process) { + validateName(name); + validatePhone(phone); + this.name = name; + this.email = email; + this.phone = phone; + this.process = process; + this.isRejected = false; + } + + private void validateName(String name) { + if (name.isBlank()) { + throw new ApplicantNameBlankException(); + } + if (isLengthOutOfRange(name)) { + throw new ApplicantNameLengthException(MAX_NAME_LENGTH, name.length()); + } + if (containsInvalidCharacter(name)) { + String invalidCharacters = Stream.of(NAME_PATTERN.matcher(name).replaceAll("").split("")) + .distinct() + .collect(Collectors.joining(", ")); + throw new ApplicantNameCharacterException(invalidCharacters); + } + } + + private void validatePhone(String phoneNumber) { + if (!PHONE_PATTERN.matcher(phoneNumber).matches()) { + throw new ApplicantIllegalPhoneNumberException(); + } + } + + private boolean isLengthOutOfRange(String name) { + return name.length() > MAX_NAME_LENGTH; + } + + private boolean containsInvalidCharacter(String name) { + return !NAME_PATTERN.matcher(name).matches(); + } + + public void updateInfo(String name, String email, String phone) { + validateName(name); + validatePhone(phone); + this.name = name; + this.email = email; + this.phone = phone; + } + + public void updateProcess(Process process) { + this.process = process; + } + + public void unreject() { + isRejected = false; + } + + public void reject() { + isRejected = true; + } + + public boolean isApproved() { + return process.isApproveType(); + } + + public boolean isNotRejected() { + return !isRejected; + } + + public Dashboard getDashboard() { + return process.getDashboard(); + } + + @Override + public boolean isAuthorizedBy(Member member) { + return process.isAuthorizedBy(member); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Applicant applicant = (Applicant) o; + return Objects.equals(id, applicant.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "Applicant{" + + "email='" + email + '\'' + + ", id=" + id + + ", name='" + name + '\'' + + ", phone='" + phone + '\'' + + ", process=" + process + + ", isRejected=" + isRejected + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/applicant/domain/Evaluation.java b/backend/src/main/java/com/cruru/applicant/domain/Evaluation.java new file mode 100644 index 000000000..610cce348 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/domain/Evaluation.java @@ -0,0 +1,98 @@ +package com.cruru.applicant.domain; + +import com.cruru.BaseEntity; +import com.cruru.applicant.exception.badrequest.EvaluationScoreException; +import com.cruru.auth.util.SecureResource; +import com.cruru.member.domain.Member; +import com.cruru.process.domain.Process; +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.util.Objects; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Evaluation extends BaseEntity implements SecureResource { + + private static final int MIN_SCORE = 1; + private static final int MAX_SCORE = 5; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "evaluation_id") + private Long id; + + private Integer score; + + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "process_id") + private Process process; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "applicant_id") + private Applicant applicant; + + public Evaluation(int score, String content, Process process, Applicant applicant) { + validateScore(score); + this.score = score; + this.content = content; + this.process = process; + this.applicant = applicant; + } + + private void validateScore(int score) { + if (isOutOfRange(score)) { + throw new EvaluationScoreException(MIN_SCORE, MAX_SCORE, score); + } + } + + private boolean isOutOfRange(int score) { + return score < MIN_SCORE || score > MAX_SCORE; + } + + @Override + public boolean isAuthorizedBy(Member member) { + return applicant.isAuthorizedBy(member); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Evaluation that = (Evaluation) o; + return Objects.equals(getId(), that.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(getId()); + } + + @Override + public String toString() { + return "Evaluation{" + + "id=" + id + + ", score=" + score + + ", content='" + content + '\'' + + ", process=" + process + + ", applicant=" + applicant + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/applicant/domain/dto/ApplicantCard.java b/backend/src/main/java/com/cruru/applicant/domain/dto/ApplicantCard.java new file mode 100644 index 000000000..d43338299 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/domain/dto/ApplicantCard.java @@ -0,0 +1,25 @@ +package com.cruru.applicant.domain.dto; + +import com.cruru.applicant.controller.response.ApplicantCardResponse; +import java.time.LocalDateTime; + +public record ApplicantCard( + long id, + + String name, + + LocalDateTime createdAt, + + Boolean isRejected, + + long evaluationCount, + + double averageScore, + + long processId +) { + + public ApplicantCardResponse toResponse() { + return new ApplicantCardResponse(id, name, createdAt, isRejected, (int) evaluationCount, averageScore); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/domain/repository/ApplicantRepository.java b/backend/src/main/java/com/cruru/applicant/domain/repository/ApplicantRepository.java new file mode 100644 index 000000000..95279c4fc --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/domain/repository/ApplicantRepository.java @@ -0,0 +1,42 @@ +package com.cruru.applicant.domain.repository; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.dto.ApplicantCard; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.process.domain.Process; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ApplicantRepository extends JpaRepository { + + List findAllByProcess(Process process); + + long countByProcess(Process process); + + @Query(""" + SELECT new com.cruru.applicant.domain.dto.ApplicantCard( + a.id, a.name, a.createdDate, a.isRejected, COUNT(e), COALESCE(AVG(e.score), 0.00), a.process.id + ) + FROM Applicant a + LEFT JOIN Evaluation e ON e.applicant = a + WHERE a.process IN :processes + GROUP BY a.id, a.name, a.createdDate, a.isRejected, a.process.id + """) + List findApplicantCardsByProcesses(@Param("processes") List processes); + + @Query(""" + SELECT new com.cruru.applicant.domain.dto.ApplicantCard( + a.id, a.name, a.createdDate, a.isRejected, COUNT(e), COALESCE(AVG(e.score), 0.00), a.process.id + ) + FROM Applicant a + LEFT JOIN Evaluation e ON e.applicant = a + WHERE a.process = :process + GROUP BY a.id, a.name, a.createdDate, a.isRejected + """) + List findApplicantCardsByProcess(@Param("process") Process process); + + @Query("SELECT a FROM Applicant a JOIN FETCH a.process p JOIN FETCH p.dashboard d WHERE d = :dashboard") + List findAllByDashboard(@Param("dashboard") Dashboard dashboard); +} diff --git a/backend/src/main/java/com/cruru/applicant/domain/repository/EvaluationRepository.java b/backend/src/main/java/com/cruru/applicant/domain/repository/EvaluationRepository.java new file mode 100644 index 000000000..0674fabd4 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/domain/repository/EvaluationRepository.java @@ -0,0 +1,14 @@ +package com.cruru.applicant.domain.repository; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.process.domain.Process; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EvaluationRepository extends JpaRepository { + + List findAllByProcessAndApplicant(Process process, Applicant applicant); + + void deleteByProcessId(long processId); +} diff --git a/backend/src/main/java/com/cruru/applicant/exception/ApplicantNotFoundException.java b/backend/src/main/java/com/cruru/applicant/exception/ApplicantNotFoundException.java new file mode 100644 index 000000000..22c05bf49 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/exception/ApplicantNotFoundException.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.exception; + +import com.cruru.advice.NotFoundException; + +public class ApplicantNotFoundException extends NotFoundException { + + private static final String TARGET = "지원자"; + + public ApplicantNotFoundException() { + super(TARGET); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/exception/EvaluationNotFoundException.java b/backend/src/main/java/com/cruru/applicant/exception/EvaluationNotFoundException.java new file mode 100644 index 000000000..7e63a7030 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/exception/EvaluationNotFoundException.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.exception; + +import com.cruru.advice.NotFoundException; + +public class EvaluationNotFoundException extends NotFoundException { + + private static final String TARGET = "평가"; + + public EvaluationNotFoundException() { + super(TARGET); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantIllegalPhoneNumberException.java b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantIllegalPhoneNumberException.java new file mode 100644 index 000000000..916e3469c --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantIllegalPhoneNumberException.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ApplicantIllegalPhoneNumberException extends BadRequestException { + + private static final String MESSAGE = "올바르지 않은 전화번호 형식입니다."; + + public ApplicantIllegalPhoneNumberException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameBlankException.java b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameBlankException.java new file mode 100644 index 000000000..2c546196e --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameBlankException.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.exception.badrequest; + +import com.cruru.advice.badrequest.TextBlankException; + +public class ApplicantNameBlankException extends TextBlankException { + + private static final String TEXT = "지원자 이름"; + + public ApplicantNameBlankException() { + super(TEXT); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameCharacterException.java b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameCharacterException.java new file mode 100644 index 000000000..8ecc4d1c7 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameCharacterException.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.exception.badrequest; + +import com.cruru.advice.badrequest.TextCharacterException; + +public class ApplicantNameCharacterException extends TextCharacterException { + + private static final String TEXT = "지원자 이름"; + + public ApplicantNameCharacterException(String invalidText) { + super(TEXT, invalidText); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameLengthException.java b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameLengthException.java new file mode 100644 index 000000000..6056e0ffd --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantNameLengthException.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.exception.badrequest; + +import com.cruru.advice.badrequest.TextLengthException; + +public class ApplicantNameLengthException extends TextLengthException { + + private static final String TEXT = "지원자 이름"; + + public ApplicantNameLengthException(int maxLength, int currentLength) { + super(TEXT, maxLength, currentLength); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantRejectException.java b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantRejectException.java new file mode 100644 index 000000000..2a587df15 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantRejectException.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ApplicantRejectException extends BadRequestException { + + private static final String MESSAGE = "이미 불합격한 지원자입니다."; + + public ApplicantRejectException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantUnrejectException.java b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantUnrejectException.java new file mode 100644 index 000000000..ffc1ccae5 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/exception/badrequest/ApplicantUnrejectException.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ApplicantUnrejectException extends BadRequestException { + + private static final String MESSAGE = "불합격하지 않은 지원자입니다."; + + public ApplicantUnrejectException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/exception/badrequest/EvaluationScoreException.java b/backend/src/main/java/com/cruru/applicant/exception/badrequest/EvaluationScoreException.java new file mode 100644 index 000000000..66f87b232 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/exception/badrequest/EvaluationScoreException.java @@ -0,0 +1,12 @@ +package com.cruru.applicant.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class EvaluationScoreException extends BadRequestException { + + private static final String MESSAGE = "평가 점수는 %d~%d 범위 안에 있어야 합니다. 현재 점수: %d."; + + public EvaluationScoreException(int minScore, int maxScore, int currentScore) { + super(String.format(MESSAGE, minScore, maxScore, currentScore)); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/facade/ApplicantFacade.java b/backend/src/main/java/com/cruru/applicant/facade/ApplicantFacade.java new file mode 100644 index 000000000..0cafd8962 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/facade/ApplicantFacade.java @@ -0,0 +1,68 @@ +package com.cruru.applicant.facade; + +import com.cruru.applicant.controller.request.ApplicantMoveRequest; +import com.cruru.applicant.controller.request.ApplicantUpdateRequest; +import com.cruru.applicant.controller.response.ApplicantAnswerResponses; +import com.cruru.applicant.controller.response.ApplicantBasicResponse; +import com.cruru.applicant.controller.response.ApplicantResponse; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.service.ApplicantService; +import com.cruru.process.controller.response.ProcessSimpleResponse; +import com.cruru.process.domain.Process; +import com.cruru.process.service.ProcessService; +import com.cruru.question.controller.response.AnswerResponse; +import com.cruru.question.domain.Answer; +import com.cruru.question.service.AnswerService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ApplicantFacade { + + private final ApplicantService applicantService; + private final ProcessService processService; + private final AnswerService answerService; + + public ApplicantBasicResponse readBasicById(long applicantId) { + Applicant applicant = applicantService.findById(applicantId); + return toApplicantBasicResponse(applicant); + } + + private ApplicantBasicResponse toApplicantBasicResponse(Applicant applicant) { + ApplicantResponse applicantResponse = applicantService.toApplicantResponse(applicant); + ProcessSimpleResponse processResponse = processService.toProcessSimpleResponse(applicant.getProcess()); + return new ApplicantBasicResponse(applicantResponse, processResponse); + } + + public ApplicantAnswerResponses readDetailById(long applicantId) { + Applicant applicant = applicantService.findById(applicantId); + List answers = answerService.findAllByApplicantWithQuestions(applicant); + List answerResponses = answerService.toAnswerResponses(answers); + return new ApplicantAnswerResponses(answerResponses); + } + + @Transactional + public void updateApplicantInformation(long applicantId, ApplicantUpdateRequest request) { + applicantService.updateApplicantInformation(applicantId, request); + } + + @Transactional + public void updateApplicantProcess(long processId, ApplicantMoveRequest moveRequest) { + Process process = processService.findById(processId); + applicantService.moveApplicantProcess(process, moveRequest); + } + + @Transactional + public void reject(long applicantId) { + applicantService.reject(applicantId); + } + + @Transactional + public void unreject(long applicantId) { + applicantService.unreject(applicantId); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/facade/EvaluationFacade.java b/backend/src/main/java/com/cruru/applicant/facade/EvaluationFacade.java new file mode 100644 index 000000000..e8a41d6b1 --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/facade/EvaluationFacade.java @@ -0,0 +1,64 @@ +package com.cruru.applicant.facade; + +import com.cruru.applicant.controller.request.EvaluationCreateRequest; +import com.cruru.applicant.controller.request.EvaluationUpdateRequest; +import com.cruru.applicant.controller.response.EvaluationResponse; +import com.cruru.applicant.controller.response.EvaluationResponses; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.applicant.service.ApplicantService; +import com.cruru.applicant.service.EvaluationService; +import com.cruru.process.domain.Process; +import com.cruru.process.service.ProcessService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class EvaluationFacade { + + private final EvaluationService evaluationService; + private final ApplicantService applicantService; + private final ProcessService processService; + + @Transactional + public void create(EvaluationCreateRequest request, long processId, long applicantId) { + Process process = processService.findById(processId); + Applicant applicant = applicantService.findById(applicantId); + + evaluationService.create(request, process, applicant); + } + + public EvaluationResponses readEvaluationsOfApplicantInProcess(long processId, long applicantId) { + Process process = processService.findById(processId); + Applicant applicant = applicantService.findById(applicantId); + + return toEvaluationResponses(evaluationService.findAllByProcessAndApplicant(process, applicant)); + } + + private EvaluationResponses toEvaluationResponses(List evaluations) { + List responses = evaluations.stream() + .map(this::toEvaluationResponse) + .toList(); + + return new EvaluationResponses(responses); + } + + private EvaluationResponse toEvaluationResponse(Evaluation evaluation) { + return new EvaluationResponse( + evaluation.getId(), + evaluation.getScore(), + evaluation.getContent(), + evaluation.getCreatedDate() + ); + } + + @Transactional + public void updateSingleEvaluation(EvaluationUpdateRequest request, Long evaluationId) { + Evaluation evaluation = evaluationService.findById(evaluationId); + evaluationService.update(request, evaluation); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/service/ApplicantService.java b/backend/src/main/java/com/cruru/applicant/service/ApplicantService.java new file mode 100644 index 000000000..0c68e9dff --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/service/ApplicantService.java @@ -0,0 +1,105 @@ +package com.cruru.applicant.service; + +import com.cruru.applicant.controller.request.ApplicantCreateRequest; +import com.cruru.applicant.controller.request.ApplicantMoveRequest; +import com.cruru.applicant.controller.request.ApplicantUpdateRequest; +import com.cruru.applicant.controller.response.ApplicantResponse; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.dto.ApplicantCard; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applicant.exception.ApplicantNotFoundException; +import com.cruru.applicant.exception.badrequest.ApplicantRejectException; +import com.cruru.applicant.exception.badrequest.ApplicantUnrejectException; +import com.cruru.process.domain.Process; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ApplicantService { + + private final ApplicantRepository applicantRepository; + + @Transactional + public Applicant create(ApplicantCreateRequest request, Process firstProcess) { + return applicantRepository.save(new Applicant(request.name(), request.email(), request.phone(), firstProcess)); + } + + public List findAllByProcess(Process process) { + return applicantRepository.findAllByProcess(process); + } + + @Transactional + public void updateApplicantInformation(long applicantId, ApplicantUpdateRequest request) { + Applicant applicant = findById(applicantId); + if (changeExists(request, applicant)) { + applicant.updateInfo(request.name(), request.email(), request.phone()); + } + } + + public Applicant findById(Long applicantId) { + return applicantRepository.findById(applicantId) + .orElseThrow(ApplicantNotFoundException::new); + } + + private boolean changeExists(ApplicantUpdateRequest request, Applicant applicant) { + return !(applicant.getName().equals(request.name()) + && applicant.getEmail().equals(request.email()) + && applicant.getPhone().equals(request.phone()) + ); + } + + @Transactional + public void moveApplicantProcess(Process process, ApplicantMoveRequest moveRequest) { + List applicants = applicantRepository.findAllById(moveRequest.applicantIds()); + applicants.forEach(applicant -> applicant.updateProcess(process)); + } + + @Transactional + public void reject(long applicantId) { + Applicant applicant = findById(applicantId); + validateRejectable(applicant); + applicant.reject(); + } + + private void validateRejectable(Applicant applicant) { + if (applicant.isRejected()) { + throw new ApplicantRejectException(); + } + } + + @Transactional + public void unreject(long applicantId) { + Applicant applicant = findById(applicantId); + validateUnrejectable(applicant); + applicant.unreject(); + } + + private void validateUnrejectable(Applicant applicant) { + if (applicant.isNotRejected()) { + throw new ApplicantUnrejectException(); + } + } + + public ApplicantResponse toApplicantResponse(Applicant applicant) { + return new ApplicantResponse( + applicant.getId(), + applicant.getName(), + applicant.getEmail(), + applicant.getPhone(), + applicant.isRejected(), + applicant.getCreatedDate() + ); + } + + public List findApplicantCards(List processes) { + return applicantRepository.findApplicantCardsByProcesses(processes); + } + + public List findApplicantCards(Process process) { + return applicantRepository.findApplicantCardsByProcess(process); + } +} diff --git a/backend/src/main/java/com/cruru/applicant/service/EvaluationService.java b/backend/src/main/java/com/cruru/applicant/service/EvaluationService.java new file mode 100644 index 000000000..b02b5f24f --- /dev/null +++ b/backend/src/main/java/com/cruru/applicant/service/EvaluationService.java @@ -0,0 +1,58 @@ +package com.cruru.applicant.service; + +import com.cruru.applicant.controller.request.EvaluationCreateRequest; +import com.cruru.applicant.controller.request.EvaluationUpdateRequest; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.applicant.domain.repository.EvaluationRepository; +import com.cruru.applicant.exception.EvaluationNotFoundException; +import com.cruru.process.domain.Process; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class EvaluationService { + + private final EvaluationRepository evaluationRepository; + + public Evaluation findById(Long evaluationId) { + return evaluationRepository.findById(evaluationId) + .orElseThrow(EvaluationNotFoundException::new); + } + + @Transactional + public void create(EvaluationCreateRequest request, Process process, Applicant applicant) { + evaluationRepository.save(new Evaluation(request.score(), request.content(), process, applicant)); + } + + public List findAllByProcessAndApplicant(Process process, Applicant applicant) { + return evaluationRepository.findAllByProcessAndApplicant(process, applicant); + } + + @Transactional + public void update(EvaluationUpdateRequest request, Evaluation evaluation) { + if (changeExists(request, evaluation)) { + evaluationRepository.save( + new Evaluation( + evaluation.getId(), + request.score(), + request.content(), + evaluation.getProcess(), + evaluation.getApplicant() + ) + ); + } + } + + private boolean changeExists(EvaluationUpdateRequest request, Evaluation evaluation) { + return !(evaluation.getContent().equals(request.content()) && evaluation.getScore().equals(request.score())); + } + + public void deleteByProcess(long processId) { + evaluationRepository.deleteByProcessId(processId); + } +} diff --git a/backend/src/main/java/com/cruru/applyform/controller/ApplyFormController.java b/backend/src/main/java/com/cruru/applyform/controller/ApplyFormController.java new file mode 100644 index 000000000..d9769ab86 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/controller/ApplyFormController.java @@ -0,0 +1,49 @@ +package com.cruru.applyform.controller; + +import com.cruru.applyform.controller.request.ApplyFormSubmitRequest; +import com.cruru.applyform.controller.request.ApplyFormWriteRequest; +import com.cruru.applyform.controller.response.ApplyFormResponse; +import com.cruru.applyform.facade.ApplyFormFacade; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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("/v1/applyform") +@RequiredArgsConstructor +public class ApplyFormController { + + private final ApplyFormFacade applyFormFacade; + + @PostMapping("/{applyformId}/submit") + public ResponseEntity submit( + @RequestBody @Valid ApplyFormSubmitRequest request, + @PathVariable("applyformId") long applyFormId + ) { + applyFormFacade.submit(applyFormId, request); + return ResponseEntity.created(URI.create("/v1/applyform/" + applyFormId)).build(); + } + + @GetMapping("/{applyformId}") + public ResponseEntity read(@PathVariable("applyformId") long applyFormId) { + ApplyFormResponse response = applyFormFacade.readApplyFormById(applyFormId); + return ResponseEntity.ok(response); + } + + @PatchMapping("/{applyformId}") + public ResponseEntity update( + @RequestBody @Valid ApplyFormWriteRequest request, + @PathVariable("applyformId") Long applyFormId + ) { + applyFormFacade.update(request, applyFormId); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/cruru/applyform/controller/request/AnswerCreateRequest.java b/backend/src/main/java/com/cruru/applyform/controller/request/AnswerCreateRequest.java new file mode 100644 index 000000000..530b3d2b7 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/controller/request/AnswerCreateRequest.java @@ -0,0 +1,16 @@ +package com.cruru.applyform.controller.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; + +public record AnswerCreateRequest( + @NotNull(message = "질문 식별자는 필수 값입니다.") + @PositiveOrZero(message = "질문 식별자는 0 이상의 정수입니다.") + Long questionId, + + @NotNull(message = "응답은 필수 값입니다.") + List replies +) { + +} diff --git a/backend/src/main/java/com/cruru/applyform/controller/request/ApplyFormSubmitRequest.java b/backend/src/main/java/com/cruru/applyform/controller/request/ApplyFormSubmitRequest.java new file mode 100644 index 000000000..bb544f6f5 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/controller/request/ApplyFormSubmitRequest.java @@ -0,0 +1,22 @@ +package com.cruru.applyform.controller.request; + +import com.cruru.applicant.controller.request.ApplicantCreateRequest; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record ApplyFormSubmitRequest( + @JsonProperty("applicant") + @Valid + ApplicantCreateRequest applicantCreateRequest, + + @JsonProperty("answers") + @Valid + List answerCreateRequest, + + @NotNull(message = "개인정보 활용 동의는 필수 값입니다.") + Boolean personalDataCollection +) { + +} diff --git a/backend/src/main/java/com/cruru/applyform/controller/request/ApplyFormWriteRequest.java b/backend/src/main/java/com/cruru/applyform/controller/request/ApplyFormWriteRequest.java new file mode 100644 index 000000000..7ed98e332 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/controller/request/ApplyFormWriteRequest.java @@ -0,0 +1,23 @@ +package com.cruru.applyform.controller.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +public record ApplyFormWriteRequest( + @NotBlank(message = "제목은 필수 값입니다.") + String title, + + String postingContent, + + @NotNull(message = "시작 날짜는 필수 값입니다.") + @JsonFormat(shape = JsonFormat.Shape.STRING) + LocalDateTime startDate, + + @NotNull(message = "종료 날짜는 필수 값입니다.") + @JsonFormat(shape = JsonFormat.Shape.STRING) + LocalDateTime endDate +) { + +} diff --git a/backend/src/main/java/com/cruru/applyform/controller/response/ApplyFormResponse.java b/backend/src/main/java/com/cruru/applyform/controller/response/ApplyFormResponse.java new file mode 100644 index 000000000..7467dec8d --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/controller/response/ApplyFormResponse.java @@ -0,0 +1,22 @@ +package com.cruru.applyform.controller.response; + +import com.cruru.question.controller.response.QuestionResponse; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDateTime; +import java.util.List; + +public record ApplyFormResponse( + String title, + + @JsonProperty("postingContent") + String description, + + LocalDateTime startDate, + + LocalDateTime endDate, + + @JsonProperty("questions") + List questionResponses +) { + +} diff --git a/backend/src/main/java/com/cruru/applyform/domain/ApplyForm.java b/backend/src/main/java/com/cruru/applyform/domain/ApplyForm.java new file mode 100644 index 000000000..8cfba61e6 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/domain/ApplyForm.java @@ -0,0 +1,110 @@ +package com.cruru.applyform.domain; + +import com.cruru.BaseEntity; +import com.cruru.applyform.exception.badrequest.StartDateAfterEndDateException; +import com.cruru.auth.util.SecureResource; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.member.domain.Member; +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.OneToOne; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class ApplyForm extends BaseEntity implements SecureResource { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "apply_form_id") + private Long id; + + private String title; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "start_date") + private LocalDateTime startDate; + + @Column(name = "end_date") + private LocalDateTime endDate; + + @OneToOne + @JoinColumn(name = "dashboard_id") + private Dashboard dashboard; + + public ApplyForm( + String title, + String description, + LocalDateTime startDate, + LocalDateTime endDate, + Dashboard dashboard + ) { + validateDate(startDate, endDate); + this.title = title; + this.description = description; + this.startDate = startDate; + this.endDate = endDate; + this.dashboard = dashboard; + } + + private void validateDate(LocalDateTime startDate, LocalDateTime endDate) { + validateStartDateBeforeEndDate(startDate, endDate); + } + + private void validateStartDateBeforeEndDate(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate.isAfter(endDate)) { + throw new StartDateAfterEndDateException(startDate, endDate); + } + } + + public boolean hasStarted(LocalDate now) { + return !startDate.toLocalDate().isAfter(now); + } + + @Override + public boolean isAuthorizedBy(Member member) { + return dashboard.isAuthorizedBy(member); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ApplyForm applyForm)) { + return false; + } + return Objects.equals(id, applyForm.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "ApplyForm{" + + "id=" + id + + ", title='" + title + '\'' + + ", description='" + description + '\'' + + ", startDate=" + startDate + + ", endDate=" + endDate + + ", dashboard=" + dashboard + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/applyform/domain/repository/ApplyFormRepository.java b/backend/src/main/java/com/cruru/applyform/domain/repository/ApplyFormRepository.java new file mode 100644 index 000000000..ec6bfc72d --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/domain/repository/ApplyFormRepository.java @@ -0,0 +1,31 @@ +package com.cruru.applyform.domain.repository; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.DashboardApplyFormDto; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ApplyFormRepository extends JpaRepository { + + Optional findByDashboard(Dashboard dashboard); + + @Query(""" + SELECT af FROM ApplyForm af + JOIN FETCH af.dashboard d + WHERE d.id = :dashboardId + """) + Optional findByDashboardId(long dashboardId); + + @Query(""" + SELECT new com.cruru.dashboard.domain.DashboardApplyFormDto(d, a) + FROM ApplyForm a + JOIN FETCH a.dashboard d + JOIN FETCH d.club c + WHERE c.id = :clubId + """) + List findAllByClub(@Param("clubId") Long clubId); +} diff --git a/backend/src/main/java/com/cruru/applyform/exception/ApplyFormNotFoundException.java b/backend/src/main/java/com/cruru/applyform/exception/ApplyFormNotFoundException.java new file mode 100644 index 000000000..b6175ff90 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/exception/ApplyFormNotFoundException.java @@ -0,0 +1,12 @@ +package com.cruru.applyform.exception; + +import com.cruru.advice.NotFoundException; + +public class ApplyFormNotFoundException extends NotFoundException { + + private static final String TEXT = "지원서 폼"; + + public ApplyFormNotFoundException() { + super(TEXT); + } +} diff --git a/backend/src/main/java/com/cruru/applyform/exception/badrequest/ApplyFormSubmitOutOfPeriodException.java b/backend/src/main/java/com/cruru/applyform/exception/badrequest/ApplyFormSubmitOutOfPeriodException.java new file mode 100644 index 000000000..e91d8f877 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/exception/badrequest/ApplyFormSubmitOutOfPeriodException.java @@ -0,0 +1,12 @@ +package com.cruru.applyform.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ApplyFormSubmitOutOfPeriodException extends BadRequestException { + + private static final String MESSAGE = "접수 기간이 아닙니다."; + + public ApplyFormSubmitOutOfPeriodException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/applyform/exception/badrequest/PersonalDataCollectDisagreeException.java b/backend/src/main/java/com/cruru/applyform/exception/badrequest/PersonalDataCollectDisagreeException.java new file mode 100644 index 000000000..ad7dc4061 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/exception/badrequest/PersonalDataCollectDisagreeException.java @@ -0,0 +1,12 @@ +package com.cruru.applyform.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class PersonalDataCollectDisagreeException extends BadRequestException { + + private static final String TEXT = "개인 정보 수집에 동의하지 않았습니다."; + + public PersonalDataCollectDisagreeException() { + super(TEXT); + } +} diff --git a/backend/src/main/java/com/cruru/applyform/exception/badrequest/ReplyNotExistsException.java b/backend/src/main/java/com/cruru/applyform/exception/badrequest/ReplyNotExistsException.java new file mode 100644 index 000000000..a9fd07bd1 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/exception/badrequest/ReplyNotExistsException.java @@ -0,0 +1,12 @@ +package com.cruru.applyform.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ReplyNotExistsException extends BadRequestException { + + private static final String MESSAGE = "응답하지 않은 필수 질문이 존재합니다."; + + public ReplyNotExistsException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/applyform/exception/badrequest/StartDateAfterEndDateException.java b/backend/src/main/java/com/cruru/applyform/exception/badrequest/StartDateAfterEndDateException.java new file mode 100644 index 000000000..891c7c38e --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/exception/badrequest/StartDateAfterEndDateException.java @@ -0,0 +1,13 @@ +package com.cruru.applyform.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; +import java.time.LocalDateTime; + +public class StartDateAfterEndDateException extends BadRequestException { + + private static final String TEXT = "접수 시작일 (%s)이 마감일 (%s)보다 늦을 수 없습니다."; + + public StartDateAfterEndDateException(LocalDateTime startDate, LocalDateTime endDate) { + super(String.format(TEXT, startDate, endDate)); + } +} diff --git a/backend/src/main/java/com/cruru/applyform/exception/badrequest/StartDatePastException.java b/backend/src/main/java/com/cruru/applyform/exception/badrequest/StartDatePastException.java new file mode 100644 index 000000000..11608392d --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/exception/badrequest/StartDatePastException.java @@ -0,0 +1,13 @@ +package com.cruru.applyform.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; +import java.time.LocalDate; + +public class StartDatePastException extends BadRequestException { + + private static final String TEXT = "접수 시작일 (%s)이 현재 날짜 (%s)보다 이전일 수 없습니다."; + + public StartDatePastException(LocalDate startDate, LocalDate now) { + super(String.format(TEXT, startDate, now)); + } +} diff --git a/backend/src/main/java/com/cruru/applyform/facade/ApplyFormFacade.java b/backend/src/main/java/com/cruru/applyform/facade/ApplyFormFacade.java new file mode 100644 index 000000000..8a847e87d --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/facade/ApplyFormFacade.java @@ -0,0 +1,104 @@ +package com.cruru.applyform.facade; + +import com.cruru.applicant.controller.request.ApplicantCreateRequest; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.service.ApplicantService; +import com.cruru.applyform.controller.request.AnswerCreateRequest; +import com.cruru.applyform.controller.request.ApplyFormSubmitRequest; +import com.cruru.applyform.controller.request.ApplyFormWriteRequest; +import com.cruru.applyform.controller.response.ApplyFormResponse; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.exception.badrequest.ApplyFormSubmitOutOfPeriodException; +import com.cruru.applyform.exception.badrequest.PersonalDataCollectDisagreeException; +import com.cruru.applyform.service.ApplyFormService; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.process.domain.Process; +import com.cruru.process.service.ProcessService; +import com.cruru.question.domain.Question; +import com.cruru.question.service.AnswerService; +import com.cruru.question.service.QuestionService; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ApplyFormFacade { + + private final QuestionService questionService; + private final ApplyFormService applyFormService; + private final ProcessService processService; + private final ApplicantService applicantService; + private final AnswerService answerService; + private final Clock clock; + + public ApplyFormResponse readApplyFormById(long applyFormId) { + ApplyForm applyForm = applyFormService.findById(applyFormId); + List questions = questionService.findByApplyForm(applyForm); + + return new ApplyFormResponse( + applyForm.getTitle(), + applyForm.getDescription(), + applyForm.getStartDate(), + applyForm.getEndDate(), + questionService.toQuestionResponses(questions) + ); + } + + @Transactional + public void submit(long applyFormId, ApplyFormSubmitRequest request) { + validatePersonalDataCollection(request); + ApplyForm applyForm = applyFormService.findById(applyFormId); + validateSubmitDate(applyForm); + Dashboard dashboard = applyForm.getDashboard(); + Process firstProcess = processService.findApplyProcessOnDashboard(dashboard); + + ApplicantCreateRequest applicantCreateRequest = request.applicantCreateRequest(); + Applicant applicant = applicantService.create(applicantCreateRequest, firstProcess); + + List answerCreateRequests = request.answerCreateRequest(); + List questions = questionService.findByApplyForm(applyForm); + + for (Question question : questions) { + AnswerCreateRequest answerCreateRequest = getAnswerCreateRequest(question, answerCreateRequests); + answerService.saveAnswerReplies(answerCreateRequest, question, applicant); + } + } + + private void validatePersonalDataCollection(ApplyFormSubmitRequest request) { + if (!request.personalDataCollection()) { + throw new PersonalDataCollectDisagreeException(); + } + } + + private void validateSubmitDate(ApplyForm applyForm) { + LocalDate now = LocalDate.now(clock); + // 추후 날짜가 아닌 시간까지 검증하는 경우 수정 필요 + LocalDate startDate = applyForm.getStartDate().toLocalDate(); + LocalDate endDate = applyForm.getEndDate().toLocalDate(); + if (now.isBefore(startDate) || now.isAfter(endDate)) { + throw new ApplyFormSubmitOutOfPeriodException(); + } + } + + private AnswerCreateRequest getAnswerCreateRequest( + Question question, + List answerCreateRequests + ) { + return answerCreateRequests.stream() + .filter(answerCreateRequest -> Objects.equals(answerCreateRequest.questionId(), question.getId())) + .findAny() + .orElseGet(() -> new AnswerCreateRequest(question.getId(), List.of())); + } + + @Transactional + public void update(ApplyFormWriteRequest request, long applyFormId) { + ApplyForm updateTargetApplyForm = applyFormService.findById(applyFormId); + applyFormService.update(updateTargetApplyForm, request); + } +} diff --git a/backend/src/main/java/com/cruru/applyform/service/ApplyFormService.java b/backend/src/main/java/com/cruru/applyform/service/ApplyFormService.java new file mode 100644 index 000000000..156576b65 --- /dev/null +++ b/backend/src/main/java/com/cruru/applyform/service/ApplyFormService.java @@ -0,0 +1,89 @@ +package com.cruru.applyform.service; + +import com.cruru.applyform.controller.request.ApplyFormWriteRequest; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.applyform.exception.ApplyFormNotFoundException; +import com.cruru.applyform.exception.badrequest.StartDatePastException; +import com.cruru.dashboard.domain.Dashboard; +import java.time.Clock; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ApplyFormService { + + private final ApplyFormRepository applyFormRepository; + private final Clock clock; + + @Transactional + public ApplyForm create(ApplyFormWriteRequest request, Dashboard createdDashboard) { + ApplyForm applyForm = toApplyForm(request, createdDashboard); + validateStartDateNotInPast(applyForm); + + return applyFormRepository.save(applyForm); + } + + private ApplyForm toApplyForm(ApplyFormWriteRequest request, Dashboard createdDashboard) { + return new ApplyForm( + request.title(), + request.postingContent(), + request.startDate(), + request.endDate(), + createdDashboard + ); + } + + private void validateStartDateNotInPast(ApplyForm applyForm) { + LocalDate startDate = applyForm.getStartDate().toLocalDate(); + LocalDate now = LocalDate.now(clock); + if (startDate.isBefore(now)) { + throw new StartDatePastException(startDate, now); + } + } + + @Transactional + public void update(ApplyForm targetApplyForm, ApplyFormWriteRequest updateRequest) { + if (changeExists(targetApplyForm, updateRequest)) { + applyFormRepository.save(toUpdateApplyForm(targetApplyForm, updateRequest)); + } + } + + private boolean changeExists(ApplyForm applyForm, ApplyFormWriteRequest updateRequest) { + return !(applyForm.getTitle().equals(updateRequest.title()) && + applyForm.getDescription().equals(updateRequest.postingContent()) && + applyForm.getStartDate().equals(updateRequest.startDate()) && + applyForm.getEndDate().equals(updateRequest.endDate()) + ); + } + + private ApplyForm toUpdateApplyForm(ApplyForm targetApplyForm, ApplyFormWriteRequest updateRequest) { + return new ApplyForm( + targetApplyForm.getId(), + updateRequest.title(), + updateRequest.postingContent(), + updateRequest.startDate(), + updateRequest.endDate(), + targetApplyForm.getDashboard() + ); + } + + public ApplyForm findById(Long applyFormId) { + return applyFormRepository.findById(applyFormId) + .orElseThrow(ApplyFormNotFoundException::new); + } + + public ApplyForm findByDashboardId(Long dashboardId) { + return applyFormRepository.findByDashboardId(dashboardId) + .orElseThrow(ApplyFormNotFoundException::new); + } + + public ApplyForm findByDashboard(Dashboard dashboard) { + return applyFormRepository.findByDashboard(dashboard) + .orElseThrow(ApplyFormNotFoundException::new); + } +} diff --git a/backend/src/main/java/com/cruru/auth/annotation/RequireAuthCheck.java b/backend/src/main/java/com/cruru/auth/annotation/RequireAuthCheck.java new file mode 100644 index 000000000..7a948df13 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/annotation/RequireAuthCheck.java @@ -0,0 +1,16 @@ +package com.cruru.auth.annotation; + +import com.cruru.auth.util.SecureResource; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireAuthCheck { + + String targetId(); // 권한을 확인할 대상 리소스 (예: "clubId", "dashboardId") + + Class targetDomain(); +} diff --git a/backend/src/main/java/com/cruru/auth/aspect/AuthCheckAspect.java b/backend/src/main/java/com/cruru/auth/aspect/AuthCheckAspect.java new file mode 100644 index 000000000..e26bffe55 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/aspect/AuthCheckAspect.java @@ -0,0 +1,111 @@ +package com.cruru.auth.aspect; + +import com.cruru.auth.annotation.RequireAuthCheck; +import com.cruru.auth.util.AuthChecker; +import com.cruru.auth.util.SecureResource; +import com.cruru.global.LoginProfile; +import com.cruru.member.domain.Member; +import com.cruru.member.service.MemberService; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Optional; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.hibernate.Hibernate; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class AuthCheckAspect { + + private static final String SERVICE_IDENTIFIER = "Service"; + + private final ApplicationContext applicationContext; // 서비스 빈을 동적으로 가져오기 위해 ApplicationContext 사용 + private final MemberService memberService; + + @Before("@annotation(com.cruru.auth.annotation.RequireAuthCheck)") + public void checkAuthorization(JoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + RequireAuthCheck authCheck = method.getAnnotation(RequireAuthCheck.class); + + Object[] args = joinPoint.getArgs(); // 메서드의 실제 인자 값 + String[] parameterNames = signature.getParameterNames(); // 메서드 파라미터 이름들 + + LoginProfile loginProfile = extractLoginProfile(parameterNames, args); + Long targetId = extractTargetId(parameterNames, args, authCheck.targetId()); + + Class domainClass = authCheck.targetDomain(); + authorize(domainClass, targetId, loginProfile); + } + + private LoginProfile extractLoginProfile(String[] parameterNames, Object[] args) { + return findParameterByName(parameterNames, args, "loginProfile", LoginProfile.class) + .orElseThrow(() -> new IllegalArgumentException("loginProfile가 존재하지 않습니다.")); + } + + private Long extractTargetId(String[] parameterNames, Object[] args, String targetIdParamName) { + return findParameterByName(parameterNames, args, targetIdParamName, Long.class) + .orElseThrow(() -> new IllegalArgumentException("targetId가 존재하지 않습니다.")); + } + + // 리소스에 대한 권한 검사 로직 분리 + private void authorize( + Class domainClass, + Long targetId, + LoginProfile loginProfile + ) throws Throwable { + try { + checkAuthorizationForTarget(domainClass, targetId, loginProfile); + } catch (InvocationTargetException e) { + throw e.getCause(); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException(domainClass + ": Service 또는 findById Method가 존재하지 않습니다."); + } + } + + // 파라미터 이름과 값을 기반으로 원하는 타입의 파라미터 추출 + private Optional findParameterByName( + String[] parameterNames, + Object[] args, + String targetParamName, + Class type + ) { + return IntStream.range(0, parameterNames.length) + .filter(i -> parameterNames[i].equals(targetParamName) && type.isInstance(args[i])) + .mapToObj(i -> type.cast(args[i])) + .findFirst(); + } + + // targetDomain에 따른 권한 검사 수행 + private void checkAuthorizationForTarget( + Class targetDomain, + Long targetId, + LoginProfile loginProfile + ) throws Exception { + Member member = memberService.findByEmail(loginProfile.email()); + + // 도메인 이름을 기반으로 서비스 클래스의 이름을 동적으로 생성 + String targetDomainName = targetDomain.getSimpleName(); + String serviceName = + Character.toLowerCase(targetDomainName.charAt(0)) + targetDomainName.substring(1) + SERVICE_IDENTIFIER; + + // ApplicationContext를 통해 해당 서비스 빈을 동적으로 가져옴 + Object service = applicationContext.getBean(serviceName); + + // findById 메서드를 호출하여 해당 도메인 객체(SecureResource)를 가져옴 + Method findByIdMethod = service.getClass().getMethod("findById", Long.class); + SecureResource secureResource = (SecureResource) findByIdMethod.invoke(service, targetId); + + // Lazy Loading된 연관 엔티티를 강제 로딩함 + Hibernate.initialize(secureResource); + + AuthChecker.checkAuthority(secureResource, member); + } +} diff --git a/backend/src/main/java/com/cruru/auth/controller/AuthController.java b/backend/src/main/java/com/cruru/auth/controller/AuthController.java new file mode 100644 index 000000000..15a7ab3a1 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/controller/AuthController.java @@ -0,0 +1,44 @@ +package com.cruru.auth.controller; + +import com.cruru.auth.controller.request.LoginRequest; +import com.cruru.auth.controller.response.LoginResponse; +import com.cruru.auth.facade.AuthFacade; +import com.cruru.club.facade.ClubFacade; +import com.cruru.global.util.CookieManager; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +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 +@RequestMapping("/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthFacade authFacade; + private final ClubFacade clubFacade; + private final CookieManager cookieManager; + + @PostMapping("/login") + public ResponseEntity login(@RequestBody @Valid LoginRequest request) { + String token = authFacade.login(request); + long clubId = clubFacade.findByMemberEmail(request.email()); + ResponseCookie cookie = cookieManager.createTokenCookie(token); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(new LoginResponse(clubId)); + } + + @PostMapping("/logout") + public ResponseEntity logout() { + ResponseCookie cookie = cookieManager.clearTokenCookie(); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .build(); + } +} diff --git a/backend/src/main/java/com/cruru/auth/controller/request/LoginRequest.java b/backend/src/main/java/com/cruru/auth/controller/request/LoginRequest.java new file mode 100644 index 000000000..b2f26f5b6 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/controller/request/LoginRequest.java @@ -0,0 +1,15 @@ +package com.cruru.auth.controller.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일의 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { + +} diff --git a/backend/src/main/java/com/cruru/auth/controller/response/LoginResponse.java b/backend/src/main/java/com/cruru/auth/controller/response/LoginResponse.java new file mode 100644 index 000000000..406c3d4e3 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/controller/response/LoginResponse.java @@ -0,0 +1,11 @@ +package com.cruru.auth.controller.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record LoginResponse( + + @JsonProperty("clubId") + long clubId +) { + +} diff --git a/backend/src/main/java/com/cruru/auth/exception/IllegalCookieException.java b/backend/src/main/java/com/cruru/auth/exception/IllegalCookieException.java new file mode 100644 index 000000000..d73951e86 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/exception/IllegalCookieException.java @@ -0,0 +1,12 @@ +package com.cruru.auth.exception; + +import com.cruru.advice.badrequest.BadRequestException; + +public class IllegalCookieException extends BadRequestException { + + private static final String MESSAGE = "유효하지 않은 쿠키입니다."; + + public IllegalCookieException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/auth/exception/IllegalTokenException.java b/backend/src/main/java/com/cruru/auth/exception/IllegalTokenException.java new file mode 100644 index 000000000..738bdc257 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/exception/IllegalTokenException.java @@ -0,0 +1,12 @@ +package com.cruru.auth.exception; + +import com.cruru.advice.UnauthorizedException; + +public class IllegalTokenException extends UnauthorizedException { + + private static final String MESSAGE = "유효하지 않는 토큰입니다."; + + public IllegalTokenException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/auth/exception/LoginExpiredException.java b/backend/src/main/java/com/cruru/auth/exception/LoginExpiredException.java new file mode 100644 index 000000000..991936556 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/exception/LoginExpiredException.java @@ -0,0 +1,12 @@ +package com.cruru.auth.exception; + +import com.cruru.advice.UnauthorizedException; + +public class LoginExpiredException extends UnauthorizedException { + + private static final String MESSAGE = "로그인이 만료되었습니다."; + + public LoginExpiredException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/auth/exception/LoginFailedException.java b/backend/src/main/java/com/cruru/auth/exception/LoginFailedException.java new file mode 100644 index 000000000..ba2ea67bb --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/exception/LoginFailedException.java @@ -0,0 +1,12 @@ +package com.cruru.auth.exception; + +import com.cruru.advice.UnauthorizedException; + +public class LoginFailedException extends UnauthorizedException { + + private static final String MESSAGE = "비밀번호가 일치하지 않습니다."; + + public LoginFailedException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/auth/exception/LoginUnauthorizedException.java b/backend/src/main/java/com/cruru/auth/exception/LoginUnauthorizedException.java new file mode 100644 index 000000000..a600bdaf9 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/exception/LoginUnauthorizedException.java @@ -0,0 +1,12 @@ +package com.cruru.auth.exception; + +import com.cruru.advice.UnauthorizedException; + +public class LoginUnauthorizedException extends UnauthorizedException { + + private static final String MESSAGE = "로그인 정보가 유효하지 않습니다."; + + public LoginUnauthorizedException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/auth/facade/AuthFacade.java b/backend/src/main/java/com/cruru/auth/facade/AuthFacade.java new file mode 100644 index 000000000..1d96825dc --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/facade/AuthFacade.java @@ -0,0 +1,27 @@ +package com.cruru.auth.facade; + +import com.cruru.auth.controller.request.LoginRequest; +import com.cruru.auth.exception.LoginFailedException; +import com.cruru.auth.service.AuthService; +import com.cruru.member.domain.Member; +import com.cruru.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AuthFacade { + + private final AuthService authService; + private final MemberService memberService; + + public String login(LoginRequest request) { + Member member = memberService.findByEmail(request.email()); + if (authService.isNotVerifiedPassword(request.password(), member.getPassword())) { + throw new LoginFailedException(); + } + return authService.createToken(member); + } +} diff --git a/backend/src/main/java/com/cruru/auth/security/PasswordValidator.java b/backend/src/main/java/com/cruru/auth/security/PasswordValidator.java new file mode 100644 index 000000000..b44b0676c --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/security/PasswordValidator.java @@ -0,0 +1,19 @@ +package com.cruru.auth.security; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class PasswordValidator { + + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + public String encode(String password) { + return passwordEncoder.encode(password); + } + + public boolean matches(String rawPassword, String encodedPassword) { + return passwordEncoder.matches(rawPassword, encodedPassword); + } +} diff --git a/backend/src/main/java/com/cruru/auth/security/TokenProperties.java b/backend/src/main/java/com/cruru/auth/security/TokenProperties.java new file mode 100644 index 000000000..e96192294 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/security/TokenProperties.java @@ -0,0 +1,8 @@ +package com.cruru.auth.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("security.jwt.token") +public record TokenProperties(String secretKey, Long expireLength, String algorithm) { + +} diff --git a/backend/src/main/java/com/cruru/auth/security/TokenProvider.java b/backend/src/main/java/com/cruru/auth/security/TokenProvider.java new file mode 100644 index 000000000..49ab720c0 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/security/TokenProvider.java @@ -0,0 +1,13 @@ +package com.cruru.auth.security; + +import com.cruru.auth.exception.IllegalTokenException; +import java.util.Map; + +public interface TokenProvider { + + String createToken(Map claims); + + boolean isAlive(String token) throws IllegalTokenException; + + String extractClaim(String token, String key) throws IllegalTokenException; +} diff --git a/backend/src/main/java/com/cruru/auth/security/jwt/JwtTokenProvider.java b/backend/src/main/java/com/cruru/auth/security/jwt/JwtTokenProvider.java new file mode 100644 index 000000000..975bb9474 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/security/jwt/JwtTokenProvider.java @@ -0,0 +1,62 @@ +package com.cruru.auth.security.jwt; + +import com.cruru.auth.exception.IllegalTokenException; +import com.cruru.auth.security.TokenProperties; +import com.cruru.auth.security.TokenProvider; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider implements TokenProvider { + + private final TokenProperties tokenProperties; + + @Override + public String createToken(Map claims) { + Date now = new Date(); + Date validity = new Date(now.getTime() + tokenProperties.expireLength()); + + return Jwts.builder() + .addClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(SignatureAlgorithm.valueOf(tokenProperties.algorithm()), + tokenProperties.secretKey().getBytes()) + .compact(); + } + + @Override + public boolean isAlive(String token) throws IllegalTokenException { + Claims claims = extractClaims(token); + Date expiration = claims.getExpiration(); + Date now = new Date(); + return expiration.after(now); + } + + private Claims extractClaims(String token) { + try { + return Jwts.parser() + .setSigningKey(tokenProperties.secretKey().getBytes()) + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } catch (JwtException | IllegalArgumentException e) { + throw new IllegalTokenException(); + } + } + + @Override + public String extractClaim(String token, String key) throws IllegalTokenException { + Claims claims = extractClaims(token); + return claims.get(key, String.class); + } +} diff --git a/backend/src/main/java/com/cruru/auth/service/AuthService.java b/backend/src/main/java/com/cruru/auth/service/AuthService.java new file mode 100644 index 000000000..a777b467c --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/service/AuthService.java @@ -0,0 +1,59 @@ +package com.cruru.auth.service; + +import com.cruru.auth.exception.IllegalTokenException; +import com.cruru.auth.security.PasswordValidator; +import com.cruru.auth.security.TokenProvider; +import com.cruru.member.domain.Member; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AuthService { + + private static final String EMAIL_CLAIM = "email"; + private static final String ROLE_CLAIM = "role"; + + private final TokenProvider tokenProvider; + private final PasswordValidator passwordValidator; + + public String createToken(Member member) { + Map claims = new HashMap<>(); + claims.put(EMAIL_CLAIM, member.getEmail()); + claims.put(ROLE_CLAIM, member.getRole().name()); + + return tokenProvider.createToken(claims); + } + + public boolean isTokenValid(String token) { + try { + return tokenProvider.isAlive(token); + } catch (IllegalTokenException e) { + return false; + } + } + + public String extractEmail(String token) { + return extractClaim(token, EMAIL_CLAIM); + } + + private String extractClaim(String token, String key) { + String claim = tokenProvider.extractClaim(token, key); + if (claim == null) { + throw new IllegalTokenException(); + } + return claim; + } + + public String extractMemberRole(String token) { + return extractClaim(token, ROLE_CLAIM); + } + + public boolean isNotVerifiedPassword(String rawPassword, String encodedPassword) { + return !passwordValidator.matches(rawPassword, encodedPassword); + } +} diff --git a/backend/src/main/java/com/cruru/auth/util/AuthChecker.java b/backend/src/main/java/com/cruru/auth/util/AuthChecker.java new file mode 100644 index 000000000..9a7de70bc --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/util/AuthChecker.java @@ -0,0 +1,21 @@ +package com.cruru.auth.util; + +import com.cruru.advice.ForbiddenException; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.MemberRole; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AuthChecker { + + public static void checkAuthority(SecureResource resource, Member member) { + if (member.getRole() == MemberRole.ADMIN) { + return; + } + + if (!resource.isAuthorizedBy(member)) { + throw new ForbiddenException(); + } + } +} diff --git a/backend/src/main/java/com/cruru/auth/util/SecureResource.java b/backend/src/main/java/com/cruru/auth/util/SecureResource.java new file mode 100644 index 000000000..846c039d7 --- /dev/null +++ b/backend/src/main/java/com/cruru/auth/util/SecureResource.java @@ -0,0 +1,8 @@ +package com.cruru.auth.util; + +import com.cruru.member.domain.Member; + +public interface SecureResource { + + boolean isAuthorizedBy(Member member); +} diff --git a/backend/src/main/java/com/cruru/club/controller/ClubController.java b/backend/src/main/java/com/cruru/club/controller/ClubController.java new file mode 100644 index 000000000..fc0a8d8c8 --- /dev/null +++ b/backend/src/main/java/com/cruru/club/controller/ClubController.java @@ -0,0 +1,30 @@ +package com.cruru.club.controller; + +import com.cruru.club.controller.request.ClubCreateRequest; +import com.cruru.club.facade.ClubFacade; +import jakarta.validation.Valid; +import java.net.URI; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/clubs") +@RequiredArgsConstructor +public class ClubController { + + private final ClubFacade clubFacade; + + @PostMapping + public ResponseEntity create( + @RequestBody @Valid ClubCreateRequest request, + @RequestParam(name = "memberId") Long memberId + ) { + long clubId = clubFacade.create(request, memberId); + return ResponseEntity.created(URI.create("/v1/clubs/" + clubId)).build(); + } +} diff --git a/backend/src/main/java/com/cruru/club/controller/request/ClubCreateRequest.java b/backend/src/main/java/com/cruru/club/controller/request/ClubCreateRequest.java new file mode 100644 index 000000000..59e153b53 --- /dev/null +++ b/backend/src/main/java/com/cruru/club/controller/request/ClubCreateRequest.java @@ -0,0 +1,10 @@ +package com.cruru.club.controller.request; + +import jakarta.validation.constraints.NotBlank; + +public record ClubCreateRequest( + @NotBlank(message = "동아리 이름은 필수 값입니다.") + String name +) { + +} diff --git a/backend/src/main/java/com/cruru/club/domain/Club.java b/backend/src/main/java/com/cruru/club/domain/Club.java new file mode 100644 index 000000000..353b75817 --- /dev/null +++ b/backend/src/main/java/com/cruru/club/domain/Club.java @@ -0,0 +1,104 @@ +package com.cruru.club.domain; + +import com.cruru.auth.util.SecureResource; +import com.cruru.club.exception.badrequest.ClubNameBlankException; +import com.cruru.club.exception.badrequest.ClubNameCharacterException; +import com.cruru.club.exception.badrequest.ClubNameLengthException; +import com.cruru.member.domain.Member; +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.util.Objects; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Club implements SecureResource { + + private static final int MAX_NAME_LENGTH = 32; + private static final Pattern NAME_PATTERN = Pattern.compile("^[^\\\\|]*$"); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "club_id") + private Long id; + + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + public Club(String name, Member member) { + validateName(name); + this.name = name; + this.member = member; + } + + private void validateName(String name) { + if (name.isBlank()) { + throw new ClubNameBlankException(); + } + if (isLengthOutOfRange(name)) { + throw new ClubNameLengthException(MAX_NAME_LENGTH, name.length()); + } + if (isContainingInvalidCharacter(name)) { + String invalidCharacters = Stream.of(NAME_PATTERN.matcher(name).replaceAll("").split("")) + .distinct() + .collect(Collectors.joining(", ")); + throw new ClubNameCharacterException(invalidCharacters); + } + } + + private boolean isLengthOutOfRange(String name) { + return name.length() > MAX_NAME_LENGTH; + } + + private boolean isContainingInvalidCharacter(String name) { + return !NAME_PATTERN.matcher(name).matches(); + } + + @Override + public boolean isAuthorizedBy(Member member) { + return this.member.equals(member); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Club club = (Club) o; + return Objects.equals(id, club.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "Club{" + + "id=" + id + + ", name='" + name + '\'' + + ", member=" + member + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/club/domain/repository/ClubRepository.java b/backend/src/main/java/com/cruru/club/domain/repository/ClubRepository.java new file mode 100644 index 000000000..352839215 --- /dev/null +++ b/backend/src/main/java/com/cruru/club/domain/repository/ClubRepository.java @@ -0,0 +1,11 @@ +package com.cruru.club.domain.repository; + +import com.cruru.club.domain.Club; +import com.cruru.member.domain.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ClubRepository extends JpaRepository { + + Optional findByMember(Member member); +} diff --git a/backend/src/main/java/com/cruru/club/exception/ClubNotFoundException.java b/backend/src/main/java/com/cruru/club/exception/ClubNotFoundException.java new file mode 100644 index 000000000..17ebd00b8 --- /dev/null +++ b/backend/src/main/java/com/cruru/club/exception/ClubNotFoundException.java @@ -0,0 +1,12 @@ +package com.cruru.club.exception; + +import com.cruru.advice.NotFoundException; + +public class ClubNotFoundException extends NotFoundException { + + private static final String TARGET = "동아리"; + + public ClubNotFoundException() { + super(TARGET); + } +} diff --git a/backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameBlankException.java b/backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameBlankException.java new file mode 100644 index 000000000..e32d18312 --- /dev/null +++ b/backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameBlankException.java @@ -0,0 +1,12 @@ +package com.cruru.club.exception.badrequest; + +import com.cruru.advice.badrequest.TextBlankException; + +public class ClubNameBlankException extends TextBlankException { + + private static final String TEXT = "동아리 이름"; + + public ClubNameBlankException() { + super(TEXT); + } +} diff --git a/backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameCharacterException.java b/backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameCharacterException.java new file mode 100644 index 000000000..2a628eb55 --- /dev/null +++ b/backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameCharacterException.java @@ -0,0 +1,12 @@ +package com.cruru.club.exception.badrequest; + +import com.cruru.advice.badrequest.TextCharacterException; + +public class ClubNameCharacterException extends TextCharacterException { + + private static final String TEXT = "동아리 이름"; + + public ClubNameCharacterException(String invalidText) { + super(TEXT, invalidText); + } +} diff --git a/backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameLengthException.java b/backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameLengthException.java new file mode 100644 index 000000000..1ee1d077d --- /dev/null +++ b/backend/src/main/java/com/cruru/club/exception/badrequest/ClubNameLengthException.java @@ -0,0 +1,12 @@ +package com.cruru.club.exception.badrequest; + +import com.cruru.advice.badrequest.TextLengthException; + +public class ClubNameLengthException extends TextLengthException { + + private static final String TEXT = "동아리 이름"; + + public ClubNameLengthException(int maxLength, int currentLength) { + super(TEXT, maxLength, currentLength); + } +} diff --git a/backend/src/main/java/com/cruru/club/facade/ClubFacade.java b/backend/src/main/java/com/cruru/club/facade/ClubFacade.java new file mode 100644 index 000000000..de1982c90 --- /dev/null +++ b/backend/src/main/java/com/cruru/club/facade/ClubFacade.java @@ -0,0 +1,33 @@ +package com.cruru.club.facade; + +import com.cruru.club.controller.request.ClubCreateRequest; +import com.cruru.club.domain.Club; +import com.cruru.club.service.ClubService; +import com.cruru.member.domain.Member; +import com.cruru.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ClubFacade { + + private final ClubService clubService; + private final MemberService memberService; + + @Transactional + public Long create(ClubCreateRequest request, long memberId) { + Member clubOwner = memberService.findById(memberId); + Club createdClub = clubService.create(request, clubOwner); + + return createdClub.getId(); + } + + public Long findByMemberEmail(String email) { + Member member = memberService.findByEmail(email); + Club club = clubService.findByMember(member); + return club.getId(); + } +} diff --git a/backend/src/main/java/com/cruru/club/service/ClubService.java b/backend/src/main/java/com/cruru/club/service/ClubService.java new file mode 100644 index 000000000..56f950fbe --- /dev/null +++ b/backend/src/main/java/com/cruru/club/service/ClubService.java @@ -0,0 +1,38 @@ +package com.cruru.club.service; + +import com.cruru.club.controller.request.ClubCreateRequest; +import com.cruru.club.domain.Club; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.club.exception.ClubNotFoundException; +import com.cruru.member.domain.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ClubService { + + private final ClubRepository clubRepository; + + @Transactional + public Club create(ClubCreateRequest request, Member member) { + return clubRepository.save(new Club(request.name(), member)); + } + + @Transactional + public Club create(String name, Member member) { + return clubRepository.save(new Club(name, member)); + } + + public Club findById(Long id) { + return clubRepository.findById(id) + .orElseThrow(ClubNotFoundException::new); + } + + public Club findByMember(Member member) { + return clubRepository.findByMember(member) + .orElseThrow(ClubNotFoundException::new); + } +} diff --git a/backend/src/main/java/com/cruru/config/AsyncConfig.java b/backend/src/main/java/com/cruru/config/AsyncConfig.java new file mode 100644 index 000000000..5bd6f8097 --- /dev/null +++ b/backend/src/main/java/com/cruru/config/AsyncConfig.java @@ -0,0 +1,19 @@ +package com.cruru.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +@Configuration +public class AsyncConfig implements AsyncConfigurer { + + private static final int THREAD_POOL_SIZE = 300; + + @Override + public Executor getAsyncExecutor() { + return Executors.newFixedThreadPool(THREAD_POOL_SIZE); + } +} diff --git a/backend/src/main/java/com/cruru/config/ClockConfig.java b/backend/src/main/java/com/cruru/config/ClockConfig.java new file mode 100644 index 000000000..e20747cb5 --- /dev/null +++ b/backend/src/main/java/com/cruru/config/ClockConfig.java @@ -0,0 +1,15 @@ +package com.cruru.config; + +import java.time.Clock; +import java.time.ZoneId; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.system(ZoneId.of("Asia/Seoul")); + } +} diff --git a/backend/src/main/java/com/cruru/config/DataSourceConfig.java b/backend/src/main/java/com/cruru/config/DataSourceConfig.java new file mode 100644 index 000000000..6f52300e7 --- /dev/null +++ b/backend/src/main/java/com/cruru/config/DataSourceConfig.java @@ -0,0 +1,59 @@ +package com.cruru.config; + +import com.zaxxer.hikari.HikariDataSource; +import java.util.HashMap; +import java.util.Map; +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.DependsOn; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; + +@Configuration +public class DataSourceConfig { + + private static final String READ_DATASOURCE = "readDataSource"; + private static final String WRITE_DATASOURCE = "writeDataSource"; + private static final String ROUTE_DATASOURCE = "routeDataSource"; + + @Bean(name = READ_DATASOURCE) + @ConfigurationProperties(prefix = "spring.datasource.read") + public DataSource readDataSource() { + return DataSourceBuilder.create() + .type(HikariDataSource.class) + .build(); + } + + @Bean(name = WRITE_DATASOURCE) + @ConfigurationProperties(prefix = "spring.datasource.write") + public DataSource writeDataSource() { + return DataSourceBuilder.create() + .type(HikariDataSource.class) + .build(); + } + + @Bean(name = ROUTE_DATASOURCE) + @DependsOn({READ_DATASOURCE, WRITE_DATASOURCE}) + public DataSourceRouter routeDataSource() { + DataSourceRouter dataSourceRouter = new DataSourceRouter(); + DataSource writeDataSource = writeDataSource(); + DataSource readDataSource = readDataSource(); + + Map dataSourceMap = new HashMap<>(); + dataSourceMap.put(DataSourceRouter.READ_DATASOURCE_KEY, readDataSource); + dataSourceMap.put(DataSourceRouter.WRITE_DATASOURCE_KEY, writeDataSource); + dataSourceRouter.setTargetDataSources(dataSourceMap); + dataSourceRouter.setDefaultTargetDataSource(writeDataSource()); + return dataSourceRouter; + } + + @Bean + @Primary + @DependsOn(ROUTE_DATASOURCE) + public DataSource defaultDataSource() { + return new LazyConnectionDataSourceProxy(routeDataSource()); + } +} diff --git a/backend/src/main/java/com/cruru/config/DataSourceRouter.java b/backend/src/main/java/com/cruru/config/DataSourceRouter.java new file mode 100644 index 000000000..3eceaca8f --- /dev/null +++ b/backend/src/main/java/com/cruru/config/DataSourceRouter.java @@ -0,0 +1,18 @@ +package com.cruru.config; + +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +public class DataSourceRouter extends AbstractRoutingDataSource { + + public static final String READ_DATASOURCE_KEY = "read"; + public static final String WRITE_DATASOURCE_KEY = "write"; + + @Override + protected Object determineCurrentLookupKey() { + if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { + return READ_DATASOURCE_KEY; + } + return WRITE_DATASOURCE_KEY; + } +} diff --git a/backend/src/main/java/com/cruru/config/JpaAuditingConfig.java b/backend/src/main/java/com/cruru/config/JpaAuditingConfig.java new file mode 100644 index 000000000..a79b8499a --- /dev/null +++ b/backend/src/main/java/com/cruru/config/JpaAuditingConfig.java @@ -0,0 +1,10 @@ +package com.cruru.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { + +} diff --git a/backend/src/main/java/com/cruru/config/WebMvcConfig.java b/backend/src/main/java/com/cruru/config/WebMvcConfig.java new file mode 100644 index 000000000..c896bf63e --- /dev/null +++ b/backend/src/main/java/com/cruru/config/WebMvcConfig.java @@ -0,0 +1,52 @@ +package com.cruru.config; + +import com.cruru.auth.service.AuthService; +import com.cruru.global.AuthenticationInterceptor; +import com.cruru.global.LoginArgumentResolver; +import com.cruru.global.util.CookieManager; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthService authService; + private final CookieManager cookieManager; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("https://*.cruru.kr") + .allowedOrigins( + "http://localhost:3000", + "https://localhost:3000", + "https://cruru.kr", + "https://dbrc7l2k8z4lk.cloudfront.net", + "https://d22au4hc21uq4d.cloudfront.net" + ) + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(true); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new AuthenticationInterceptor(authService, cookieManager)) + .addPathPatterns("/**") + .excludePathPatterns("/**/signup") + .excludePathPatterns("/**/login") + .excludePathPatterns("/**/applyform/*/submit") + .excludePathPatterns("/"); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new LoginArgumentResolver(authService, cookieManager)); + } +} diff --git a/backend/src/main/java/com/cruru/dashboard/controller/DashboardController.java b/backend/src/main/java/com/cruru/dashboard/controller/DashboardController.java new file mode 100644 index 000000000..067f092f5 --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/controller/DashboardController.java @@ -0,0 +1,50 @@ +package com.cruru.dashboard.controller; + +import com.cruru.auth.annotation.RequireAuthCheck; +import com.cruru.club.domain.Club; +import com.cruru.dashboard.controller.request.DashboardCreateRequest; +import com.cruru.dashboard.controller.response.DashboardCreateResponse; +import com.cruru.dashboard.controller.response.DashboardsOfClubResponse; +import com.cruru.dashboard.facade.DashboardFacade; +import com.cruru.global.LoginProfile; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/dashboards") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardFacade dashboardFacade; + + @PostMapping + @RequireAuthCheck(targetId = "clubId", targetDomain = Club.class) + public ResponseEntity create( + @RequestParam(name = "clubId") Long clubId, + @RequestBody @Valid DashboardCreateRequest request, + LoginProfile loginProfile + ) { + + DashboardCreateResponse dashboardCreateResponse = dashboardFacade.create(clubId, request); + return ResponseEntity.created(URI.create("/v1/dashboards/" + dashboardCreateResponse.dashboardId())) + .body(dashboardCreateResponse); + } + + @GetMapping + @RequireAuthCheck(targetId = "clubId", targetDomain = Club.class) + public ResponseEntity readDashboards( + @RequestParam(name = "clubId") Long clubId, + LoginProfile loginProfile + ) { + DashboardsOfClubResponse dashboards = dashboardFacade.findAllDashboardsByClubId(clubId); + return ResponseEntity.ok().body(dashboards); + } +} diff --git a/backend/src/main/java/com/cruru/dashboard/controller/request/DashboardCreateRequest.java b/backend/src/main/java/com/cruru/dashboard/controller/request/DashboardCreateRequest.java new file mode 100644 index 000000000..d7ee8646a --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/controller/request/DashboardCreateRequest.java @@ -0,0 +1,30 @@ +package com.cruru.dashboard.controller.request; + +import com.cruru.question.controller.request.QuestionCreateRequest; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonFormat.Shape; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.List; + +public record DashboardCreateRequest( + @NotBlank(message = "공고 제목은 필수 값입니다.") + String title, + + String postingContent, + + @Valid + List questions, + + @NotNull(message = "시작 날짜는 필수 값입니다.") + @JsonFormat(shape = Shape.STRING) + LocalDateTime startDate, + + @NotNull(message = "종료 날짜는 필수 값입니다.") + @JsonFormat(shape = Shape.STRING) + LocalDateTime endDate +) { + +} diff --git a/backend/src/main/java/com/cruru/dashboard/controller/response/DashboardCreateResponse.java b/backend/src/main/java/com/cruru/dashboard/controller/response/DashboardCreateResponse.java new file mode 100644 index 000000000..4023ca526 --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/controller/response/DashboardCreateResponse.java @@ -0,0 +1,8 @@ +package com.cruru.dashboard.controller.response; + +public record DashboardCreateResponse( + long applyFormId, + long dashboardId +) { + +} diff --git a/backend/src/main/java/com/cruru/dashboard/controller/response/DashboardPreviewResponse.java b/backend/src/main/java/com/cruru/dashboard/controller/response/DashboardPreviewResponse.java new file mode 100644 index 000000000..43e9d11bd --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/controller/response/DashboardPreviewResponse.java @@ -0,0 +1,24 @@ +package com.cruru.dashboard.controller.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonFormat.Shape; +import java.time.LocalDateTime; + +public record DashboardPreviewResponse( + + long dashboardId, + + long applyFormId, + + String title, + + StatsResponse stats, + + @JsonFormat(shape = Shape.STRING) + LocalDateTime startDate, + + @JsonFormat(shape = Shape.STRING) + LocalDateTime endDate +) { + +} diff --git a/backend/src/main/java/com/cruru/dashboard/controller/response/DashboardsOfClubResponse.java b/backend/src/main/java/com/cruru/dashboard/controller/response/DashboardsOfClubResponse.java new file mode 100644 index 000000000..c50038fb8 --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/controller/response/DashboardsOfClubResponse.java @@ -0,0 +1,14 @@ +package com.cruru.dashboard.controller.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record DashboardsOfClubResponse( + + String clubName, + + @JsonProperty(value = "dashboards") + List dashboardPreviewResponses +) { + +} diff --git a/backend/src/main/java/com/cruru/dashboard/controller/response/StatsResponse.java b/backend/src/main/java/com/cruru/dashboard/controller/response/StatsResponse.java new file mode 100644 index 000000000..669cb63db --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/controller/response/StatsResponse.java @@ -0,0 +1,13 @@ +package com.cruru.dashboard.controller.response; + +public record StatsResponse( + int accept, + + int fail, + + int inProgress, + + int total +) { + +} diff --git a/backend/src/main/java/com/cruru/dashboard/domain/Dashboard.java b/backend/src/main/java/com/cruru/dashboard/domain/Dashboard.java new file mode 100644 index 000000000..5c5d12b87 --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/domain/Dashboard.java @@ -0,0 +1,68 @@ +package com.cruru.dashboard.domain; + +import com.cruru.auth.util.SecureResource; +import com.cruru.club.domain.Club; +import com.cruru.member.domain.Member; +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.util.Objects; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Dashboard implements SecureResource { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "dashboard_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id") + private Club club; + + public Dashboard(Club club) { + this.club = club; + } + + @Override + public boolean isAuthorizedBy(Member member) { + return club.isAuthorizedBy(member); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Dashboard dashboard = (Dashboard) o; + return Objects.equals(id, dashboard.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "Dashboard{" + + "id=" + id + + ", club=" + club + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/dashboard/domain/DashboardApplyFormDto.java b/backend/src/main/java/com/cruru/dashboard/domain/DashboardApplyFormDto.java new file mode 100644 index 000000000..adc37ab87 --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/domain/DashboardApplyFormDto.java @@ -0,0 +1,7 @@ +package com.cruru.dashboard.domain; + +import com.cruru.applyform.domain.ApplyForm; + +public record DashboardApplyFormDto(Dashboard dashboard, ApplyForm applyForm) { + +} diff --git a/backend/src/main/java/com/cruru/dashboard/domain/repository/DashboardRepository.java b/backend/src/main/java/com/cruru/dashboard/domain/repository/DashboardRepository.java new file mode 100644 index 000000000..92b8c02d6 --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/domain/repository/DashboardRepository.java @@ -0,0 +1,8 @@ +package com.cruru.dashboard.domain.repository; + +import com.cruru.dashboard.domain.Dashboard; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DashboardRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/cruru/dashboard/exception/DashboardNotFoundException.java b/backend/src/main/java/com/cruru/dashboard/exception/DashboardNotFoundException.java new file mode 100644 index 000000000..d2a5af2b1 --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/exception/DashboardNotFoundException.java @@ -0,0 +1,12 @@ +package com.cruru.dashboard.exception; + +import com.cruru.advice.NotFoundException; + +public class DashboardNotFoundException extends NotFoundException { + + private static final String TARGET = "대시보드"; + + public DashboardNotFoundException() { + super(TARGET); + } +} diff --git a/backend/src/main/java/com/cruru/dashboard/facade/DashboardFacade.java b/backend/src/main/java/com/cruru/dashboard/facade/DashboardFacade.java new file mode 100644 index 000000000..38301866d --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/facade/DashboardFacade.java @@ -0,0 +1,123 @@ +package com.cruru.dashboard.facade; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applyform.controller.request.ApplyFormWriteRequest; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.service.ApplyFormService; +import com.cruru.club.domain.Club; +import com.cruru.club.service.ClubService; +import com.cruru.dashboard.controller.request.DashboardCreateRequest; +import com.cruru.dashboard.controller.response.DashboardCreateResponse; +import com.cruru.dashboard.controller.response.DashboardPreviewResponse; +import com.cruru.dashboard.controller.response.DashboardsOfClubResponse; +import com.cruru.dashboard.controller.response.StatsResponse; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.DashboardApplyFormDto; +import com.cruru.dashboard.service.DashboardService; +import com.cruru.question.controller.request.QuestionCreateRequest; +import com.cruru.question.service.QuestionService; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class DashboardFacade { + + private final ClubService clubService; + private final DashboardService dashboardService; + private final ApplyFormService applyFormService; + private final QuestionService questionService; + private final Clock clock; + + @Transactional + public DashboardCreateResponse create(long clubId, DashboardCreateRequest request) { + Club club = clubService.findById(clubId); + + Dashboard dashboard = dashboardService.create(club); + ApplyForm applyForm = applyFormService.create(toApplyFormWriteRequest(request), dashboard); + for (QuestionCreateRequest questionCreateRequest : request.questions()) { + questionService.create(questionCreateRequest, applyForm); + } + return new DashboardCreateResponse(applyForm.getId(), dashboard.getId()); + } + + private ApplyFormWriteRequest toApplyFormWriteRequest(DashboardCreateRequest request) { + return new ApplyFormWriteRequest( + request.title(), + request.postingContent(), + request.startDate(), + request.endDate() + ); + } + + public DashboardsOfClubResponse findAllDashboardsByClubId(long clubId) { + List dashboards = dashboardService.findAllByClub(clubId); + + String clubName = clubService.findById(clubId).getName(); + LocalDateTime now = LocalDateTime.now(clock); + List dashboardResponses = dashboards.stream() + .map(this::createDashboardPreviewResponse) + .toList(); + + List sortedDashboardPreviews = sortDashboardPreviews(dashboardResponses, now); + + return new DashboardsOfClubResponse(clubName, sortedDashboardPreviews); + } + + private DashboardPreviewResponse createDashboardPreviewResponse(DashboardApplyFormDto dashboardApplyformDto) { + Dashboard dashboard = dashboardApplyformDto.dashboard(); + ApplyForm applyForm = dashboardApplyformDto.applyForm(); + + List applicants = dashboardService.findAllApplicants(dashboard); + StatsResponse stats = calculateStats(applicants); + return new DashboardPreviewResponse( + dashboard.getId(), + applyForm.getId(), + applyForm.getTitle(), + stats, + applyForm.getStartDate(), + applyForm.getEndDate() + ); + } + + private List sortDashboardPreviews( + List dashboardResponses, + LocalDateTime currentTime + ) { + List nonExpiredDashboards = dashboardResponses.stream() + .filter(d -> d.endDate().isAfter(currentTime)) + .sorted(Comparator.comparing(DashboardPreviewResponse::endDate) + .thenComparing(DashboardPreviewResponse::startDate)) + .toList(); + + List expiredDashboards = dashboardResponses.stream() + .filter(d -> d.endDate().isBefore(currentTime)) + .sorted(Comparator.comparing(DashboardPreviewResponse::startDate)) + .toList(); + + List sortedDashboards = new ArrayList<>(); + sortedDashboards.addAll(nonExpiredDashboards); + sortedDashboards.addAll(expiredDashboards); + + return sortedDashboards; + } + + private StatsResponse calculateStats(List allApplicants) { + int totalApplicants = allApplicants.size(); + int totalFails = (int) allApplicants.stream() + .filter(Applicant::isRejected).count(); + int totalAccepts = (int) allApplicants.stream() + .filter(Applicant::isNotRejected) + .filter(Applicant::isApproved) + .count(); + int totalPending = totalApplicants - (totalFails + totalAccepts); + return new StatsResponse(totalAccepts, totalFails, totalPending, totalApplicants); + } +} diff --git a/backend/src/main/java/com/cruru/dashboard/service/DashboardService.java b/backend/src/main/java/com/cruru/dashboard/service/DashboardService.java new file mode 100644 index 000000000..65694bc08 --- /dev/null +++ b/backend/src/main/java/com/cruru/dashboard/service/DashboardService.java @@ -0,0 +1,51 @@ +package com.cruru.dashboard.service; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.club.domain.Club; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.DashboardApplyFormDto; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.dashboard.exception.DashboardNotFoundException; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.ProcessFactory; +import com.cruru.process.domain.repository.ProcessRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class DashboardService { + + private final DashboardRepository dashboardRepository; + private final ProcessRepository processRepository; + private final ApplicantRepository applicantRepository; + private final ApplyFormRepository applyFormRepository; + + @Transactional + public Dashboard create(Club club) { + Dashboard savedDashboard = dashboardRepository.save(new Dashboard(club)); + + List initProcesses = ProcessFactory.createInitProcesses(savedDashboard); + processRepository.saveAll(initProcesses); + + return savedDashboard; + } + + public Dashboard findById(Long id) { + return dashboardRepository.findById(id) + .orElseThrow(DashboardNotFoundException::new); + } + + public List findAllByClub(long clubId) { + return applyFormRepository.findAllByClub(clubId); + } + + public List findAllApplicants(Dashboard dashboard) { + return applicantRepository.findAllByDashboard(dashboard); + } +} diff --git a/backend/src/main/java/com/cruru/email/controller/EmailController.java b/backend/src/main/java/com/cruru/email/controller/EmailController.java new file mode 100644 index 000000000..181f0931b --- /dev/null +++ b/backend/src/main/java/com/cruru/email/controller/EmailController.java @@ -0,0 +1,25 @@ +package com.cruru.email.controller; + +import com.cruru.email.controller.dto.EmailRequest; +import com.cruru.email.facade.EmailFacade; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/emails") +@RequiredArgsConstructor +public class EmailController { + + private final EmailFacade emailFacade; + + @PostMapping("/send") + public ResponseEntity send(@Valid @ModelAttribute EmailRequest request) { + emailFacade.send(request); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/cruru/email/controller/dto/EmailRequest.java b/backend/src/main/java/com/cruru/email/controller/dto/EmailRequest.java new file mode 100644 index 000000000..6d82b6563 --- /dev/null +++ b/backend/src/main/java/com/cruru/email/controller/dto/EmailRequest.java @@ -0,0 +1,27 @@ +package com.cruru.email.controller.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +public record EmailRequest( + + @NotNull(message = "발신자는 필수 값입니다.") + Long clubId, + + @NotEmpty(message = "수신자는 필수 값입니다.") + @Valid + List<@NotNull(message = "수신자는 필수 값입니다.") Long> applicantIds, + + @NotNull(message = "이메일 제목은 필수 값입니다.") + String subject, + + @NotNull(message = "이메일 본문은 필수 값입니다.") + String content, + + List files +) { + +} diff --git a/backend/src/main/java/com/cruru/email/domain/Email.java b/backend/src/main/java/com/cruru/email/domain/Email.java new file mode 100644 index 000000000..e91405ca7 --- /dev/null +++ b/backend/src/main/java/com/cruru/email/domain/Email.java @@ -0,0 +1,102 @@ +package com.cruru.email.domain; + +import com.cruru.BaseEntity; +import com.cruru.applicant.domain.Applicant; +import com.cruru.club.domain.Club; +import com.cruru.email.exception.EmailContentLengthException; +import com.cruru.email.exception.EmailSubjectLengthException; +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.util.Objects; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Email extends BaseEntity { + + private static final int EMAIL_SUBJECT_MAX_LENGTH = 998; + private static final int EMAIL_CONTENT_MAX_LENGTH = 10_000; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "email_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id") + private Club from; // 발신자 정보(동아리) + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "applicant_id") + private Applicant to; + + private String subject; + + @Column(columnDefinition = "TEXT") + private String content; + + @Column(name = "is_succeed") + private Boolean isSucceed; + + public Email(Club from, Applicant to, String subject, String content, Boolean isSucceed) { + validateSubjectLength(subject); + validateContentLength(content); + this.from = from; + this.to = to; + this.subject = subject; + this.content = content; + this.isSucceed = isSucceed; + } + + private void validateSubjectLength(String subject) { + if (subject.length() > EMAIL_SUBJECT_MAX_LENGTH) { + throw new EmailSubjectLengthException(EMAIL_SUBJECT_MAX_LENGTH, subject.length()); + } + } + + private void validateContentLength(String content) { + if (content.length() > EMAIL_CONTENT_MAX_LENGTH) { + throw new EmailContentLengthException(EMAIL_CONTENT_MAX_LENGTH, content.length()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Email email = (Email) o; + return Objects.equals(id, email.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "Email{" + + "id=" + id + + ", from=" + from + + ", to=" + to + + ", subject='" + subject + '\'' + + ", content='" + content + '\'' + + ", isSucceed=" + isSucceed + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/email/domain/repository/EmailRepository.java b/backend/src/main/java/com/cruru/email/domain/repository/EmailRepository.java new file mode 100644 index 000000000..eb8ef9df4 --- /dev/null +++ b/backend/src/main/java/com/cruru/email/domain/repository/EmailRepository.java @@ -0,0 +1,8 @@ +package com.cruru.email.domain.repository; + +import com.cruru.email.domain.Email; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EmailRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/cruru/email/exception/EmailAttachmentsException.java b/backend/src/main/java/com/cruru/email/exception/EmailAttachmentsException.java new file mode 100644 index 000000000..5872069d3 --- /dev/null +++ b/backend/src/main/java/com/cruru/email/exception/EmailAttachmentsException.java @@ -0,0 +1,12 @@ +package com.cruru.email.exception; + +import com.cruru.advice.InternalServerException; + +public class EmailAttachmentsException extends InternalServerException { + + private static final String MESSAGE = "첨부 파일을 정상적으로 처리하지 못했습니다. 발송자 Id: %d, 메일 제목: %s"; + + public EmailAttachmentsException(Long from, String subject) { + super(String.format(MESSAGE, from, subject)); + } +} diff --git a/backend/src/main/java/com/cruru/email/exception/EmailContentLengthException.java b/backend/src/main/java/com/cruru/email/exception/EmailContentLengthException.java new file mode 100644 index 000000000..6b2b7fe9c --- /dev/null +++ b/backend/src/main/java/com/cruru/email/exception/EmailContentLengthException.java @@ -0,0 +1,12 @@ +package com.cruru.email.exception; + +import com.cruru.advice.badrequest.TextLengthException; + +public class EmailContentLengthException extends TextLengthException { + + private static final String TEXT = "email 본문"; + + public EmailContentLengthException(int maxLength, int currentLength) { + super(TEXT, maxLength, currentLength); + } +} diff --git a/backend/src/main/java/com/cruru/email/exception/EmailSendFailedException.java b/backend/src/main/java/com/cruru/email/exception/EmailSendFailedException.java new file mode 100644 index 000000000..66b0fb1a7 --- /dev/null +++ b/backend/src/main/java/com/cruru/email/exception/EmailSendFailedException.java @@ -0,0 +1,12 @@ +package com.cruru.email.exception; + +import com.cruru.advice.InternalServerException; + +public class EmailSendFailedException extends InternalServerException { + + private static final String MESSAGE = "이메일 전송에 실패했습니다. 발송자 Id: %d, 수신자 Email: %s"; + + public EmailSendFailedException(Long from, String to) { + super(String.format(MESSAGE, from, to)); + } +} diff --git a/backend/src/main/java/com/cruru/email/exception/EmailSubjectLengthException.java b/backend/src/main/java/com/cruru/email/exception/EmailSubjectLengthException.java new file mode 100644 index 000000000..cb213b060 --- /dev/null +++ b/backend/src/main/java/com/cruru/email/exception/EmailSubjectLengthException.java @@ -0,0 +1,12 @@ +package com.cruru.email.exception; + +import com.cruru.advice.badrequest.TextLengthException; + +public class EmailSubjectLengthException extends TextLengthException { + + private static final String TEXT = "email 제목"; + + public EmailSubjectLengthException(int maxLength, int currentLength) { + super(TEXT, maxLength, currentLength); + } +} diff --git a/backend/src/main/java/com/cruru/email/facade/EmailFacade.java b/backend/src/main/java/com/cruru/email/facade/EmailFacade.java new file mode 100644 index 000000000..b68ee189a --- /dev/null +++ b/backend/src/main/java/com/cruru/email/facade/EmailFacade.java @@ -0,0 +1,55 @@ +package com.cruru.email.facade; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.service.ApplicantService; +import com.cruru.club.domain.Club; +import com.cruru.club.service.ClubService; +import com.cruru.email.controller.dto.EmailRequest; +import com.cruru.email.exception.EmailAttachmentsException; +import com.cruru.email.service.EmailService; +import com.cruru.email.util.FileUtil; +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class EmailFacade { + + private final EmailService emailService; + private final ClubService clubService; + private final ApplicantService applicantService; + + public void send(EmailRequest request) { + Club from = clubService.findById(request.clubId()); + List applicants = request.applicantIds() + .stream() + .map(applicantService::findById) + .toList(); + sendAndSave(from, applicants, request.subject(), request.content(), request.files()); + } + + private void sendAndSave(Club from, List tos, String subject, String text, List files) { + List tempFiles = saveTempFiles(from, subject, files); + + List> futures = tos.stream() + .map(to -> emailService.send(from, to, subject, text, tempFiles)) + .map(future -> future.thenAccept(emailService::save)) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenRun(() -> FileUtil.deleteFiles(tempFiles)); + } + + private List saveTempFiles(Club from, String subject, List files) { + try { + return FileUtil.saveTempFiles(files); + } catch (IOException e) { + throw new EmailAttachmentsException(from.getId(), subject); + } + } +} diff --git a/backend/src/main/java/com/cruru/email/service/EmailService.java b/backend/src/main/java/com/cruru/email/service/EmailService.java new file mode 100644 index 000000000..686c1cf20 --- /dev/null +++ b/backend/src/main/java/com/cruru/email/service/EmailService.java @@ -0,0 +1,77 @@ +package com.cruru.email.service; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.club.domain.Club; +import com.cruru.email.domain.Email; +import com.cruru.email.domain.repository.EmailRepository; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import java.io.File; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender mailSender; + private final EmailRepository emailRepository; + + @Async + public CompletableFuture send( + Club from, Applicant to, String subject, String content, List tempFiles) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + helper.setTo(to.getEmail()); + helper.setSubject(subject); + helper.setText(content); + if (hasFile(tempFiles)) { + addAttachments(helper, tempFiles); + } + mailSender.send(message); + + log.info("이메일 전송 성공: from={}, to={}, subject={}", from.getId(), to.getEmail(), subject); + return CompletableFuture.completedFuture(new Email(from, to, subject, content, true)); + } catch (MessagingException | MailException e) { + log.info("이메일 전송 실패: from={}, to={}, subject={}", from.getId(), to.getEmail(), e.getMessage()); + return CompletableFuture.completedFuture(new Email(from, to, subject, content, false)); + } + } + + private boolean hasFile(List files) { + return files != null && !files.isEmpty(); + } + + private void addAttachments(MimeMessageHelper helper, List files) throws MessagingException { + for (File file : files) { + addAttachment(helper, file); + } + } + + private void addAttachment(MimeMessageHelper helper, File file) throws MessagingException { + String fileName = file.getName(); + if (isValidateFileName(fileName)) { + helper.addAttachment(fileName, file); + } + } + + private boolean isValidateFileName(String fileName) { + return fileName != null && !fileName.isEmpty(); + } + + @Transactional + public void save(Email email) { + emailRepository.save(email); + } +} diff --git a/backend/src/main/java/com/cruru/email/util/FileUtil.java b/backend/src/main/java/com/cruru/email/util/FileUtil.java new file mode 100644 index 000000000..13a50c773 --- /dev/null +++ b/backend/src/main/java/com/cruru/email/util/FileUtil.java @@ -0,0 +1,46 @@ +package com.cruru.email.util; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FileUtil { + + private static final String FILE_PREFIX = UUID.randomUUID() + "_"; + private static final String FILE_SUFFIX = "_"; + + public static List saveTempFiles(List files) throws IOException { + if (files == null) { + return new ArrayList<>(); + } + List tempFiles = new ArrayList<>(); + for (MultipartFile file : files) { + File tempFile = File.createTempFile(FILE_PREFIX, FILE_SUFFIX + file.getOriginalFilename()); + file.transferTo(tempFile); + tempFiles.add(tempFile); + } + return tempFiles; + } + + public static void deleteFiles(List files) { + if (files != null) { + files.forEach(FileUtil::deleteFile); + } + } + + private static void deleteFile(File file) { + if (file.exists()) { + file.delete(); + return; + } + log.info("삭제할 파일이 존재하지 않습니다: {}", file.getAbsolutePath()); + } +} diff --git a/backend/src/main/java/com/cruru/global/AuthenticationInterceptor.java b/backend/src/main/java/com/cruru/global/AuthenticationInterceptor.java new file mode 100644 index 000000000..e12a389c5 --- /dev/null +++ b/backend/src/main/java/com/cruru/global/AuthenticationInterceptor.java @@ -0,0 +1,55 @@ +package com.cruru.global; + +import com.cruru.auth.exception.IllegalCookieException; +import com.cruru.auth.exception.LoginUnauthorizedException; +import com.cruru.auth.service.AuthService; +import com.cruru.global.util.CookieManager; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@RequiredArgsConstructor +public class AuthenticationInterceptor implements HandlerInterceptor { + + private static final String APPLYFORM_REQUEST_URI = "^/v1/applyform/\\d+$"; + + private final AuthService authService; + private final CookieManager cookieManager; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (isGetApplyformRequest(request)) { + return true; + } + + if (isOptionsRequest(request) || isAuthenticated(request)) { + return true; + } + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + private boolean isGetApplyformRequest(HttpServletRequest request) { + return request.getRequestURI().matches(APPLYFORM_REQUEST_URI) && isGetRequest(request); + } + + private boolean isOptionsRequest(HttpServletRequest request) { + return HttpMethod.OPTIONS.name().equalsIgnoreCase(request.getMethod()); + } + + private boolean isAuthenticated(HttpServletRequest request) { + try { + String token = cookieManager.extractToken(request); + return authService.isTokenValid(token); + } catch (IllegalCookieException e) { + throw new LoginUnauthorizedException(); + } + } + + private boolean isGetRequest(HttpServletRequest request) { + return HttpMethod.GET.name().equalsIgnoreCase(request.getMethod()); + } +} diff --git a/backend/src/main/java/com/cruru/global/LoginArgumentResolver.java b/backend/src/main/java/com/cruru/global/LoginArgumentResolver.java new file mode 100644 index 000000000..5a7e7980d --- /dev/null +++ b/backend/src/main/java/com/cruru/global/LoginArgumentResolver.java @@ -0,0 +1,43 @@ +package com.cruru.global; + +import com.cruru.advice.UnauthorizedException; +import com.cruru.auth.annotation.RequireAuthCheck; +import com.cruru.auth.service.AuthService; +import com.cruru.global.util.CookieManager; +import com.cruru.member.domain.MemberRole; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +public class LoginArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthService authService; + private final CookieManager cookieManager; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return Objects.requireNonNull(parameter.getMethod()).isAnnotationPresent(RequireAuthCheck.class); + } + + @Override + public LoginProfile resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) throws UnauthorizedException { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + + String token = cookieManager.extractToken(request); + String emailPayload = authService.extractEmail(token); + MemberRole memberRolePayload = MemberRole.valueOf(authService.extractMemberRole(token)); + + return new LoginProfile(emailPayload, memberRolePayload); + } +} diff --git a/backend/src/main/java/com/cruru/global/LoginProfile.java b/backend/src/main/java/com/cruru/global/LoginProfile.java new file mode 100644 index 000000000..0f9d71a67 --- /dev/null +++ b/backend/src/main/java/com/cruru/global/LoginProfile.java @@ -0,0 +1,7 @@ +package com.cruru.global; + +import com.cruru.member.domain.MemberRole; + +public record LoginProfile(String email, MemberRole memberRole) { + +} diff --git a/backend/src/main/java/com/cruru/global/util/CookieManager.java b/backend/src/main/java/com/cruru/global/util/CookieManager.java new file mode 100644 index 000000000..92986a4bf --- /dev/null +++ b/backend/src/main/java/com/cruru/global/util/CookieManager.java @@ -0,0 +1,60 @@ +package com.cruru.global.util; + +import com.cruru.auth.exception.IllegalCookieException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CookieManager { + + private final CookieProperties cookieProperties; + + public String extractToken(HttpServletRequest request) { + Cookie[] cookies = extractCookie(request); + return Arrays.stream(cookies) + .filter(this::isAccessTokenCookie) + .findFirst() + .map(Cookie::getValue) + .orElseThrow(IllegalCookieException::new); + } + + private Cookie[] extractCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + + if (cookies == null) { + throw new IllegalCookieException(); + } + return cookies; + } + + private boolean isAccessTokenCookie(Cookie cookie) { + return cookieProperties.accessTokenKey().equals(cookie.getName()); + } + + public ResponseCookie createTokenCookie(String token) { + return ResponseCookie.from(cookieProperties.accessTokenKey(), token) + .httpOnly(cookieProperties.httpOnly()) + .secure(cookieProperties.secure()) + .domain(cookieProperties.domain()) + .path(cookieProperties.path()) + .sameSite(cookieProperties.sameSite()) + .maxAge(cookieProperties.maxAge()) + .build(); + } + + public ResponseCookie clearTokenCookie() { + return ResponseCookie.from(cookieProperties.accessTokenKey()) + .httpOnly(cookieProperties.httpOnly()) + .secure(cookieProperties.secure()) + .domain(cookieProperties.domain()) + .path(cookieProperties.path()) + .sameSite(cookieProperties.sameSite()) + .maxAge(0) + .build(); + } +} diff --git a/backend/src/main/java/com/cruru/global/util/CookieProperties.java b/backend/src/main/java/com/cruru/global/util/CookieProperties.java new file mode 100644 index 000000000..c3454930e --- /dev/null +++ b/backend/src/main/java/com/cruru/global/util/CookieProperties.java @@ -0,0 +1,16 @@ +package com.cruru.global.util; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("cookie") +public record CookieProperties( + String accessTokenKey, + boolean httpOnly, + boolean secure, + String domain, + String path, + String sameSite, + Long maxAge +) { + +} diff --git a/backend/src/main/java/com/cruru/global/util/ExceptionLogger.java b/backend/src/main/java/com/cruru/global/util/ExceptionLogger.java new file mode 100644 index 000000000..ac1a78725 --- /dev/null +++ b/backend/src/main/java/com/cruru/global/util/ExceptionLogger.java @@ -0,0 +1,68 @@ +package com.cruru.global.util; + +import com.cruru.advice.CruruCustomException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.http.ProblemDetail; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ExceptionLogger { + + public static void info(HttpServletRequest request, CruruCustomException exception) { + setMDC(request, exception); + log.info("handle info level exception"); + clearMDC(); + } + + // MDC에 메타데이터 설정 + private static void setMDC(HttpServletRequest request, CruruCustomException exception) { + StackTraceElement origin = exception.getStackTrace()[0]; + + MDC.put("httpMethod", request.getMethod()); + MDC.put("requestUri", request.getRequestURI()); + MDC.put("statusCode", exception.statusCode()); + MDC.put("sourceClass", origin.getClassName()); + MDC.put("sourceMethod", origin.getMethodName()); + MDC.put("exceptionClass", exception.getClass().getSimpleName()); + MDC.put("exceptionMessage", exception.getMessage()); + } + + private static void clearMDC() { + MDC.clear(); + } + + // MDC 초기화 + public static void info(ProblemDetail problemDetail) { + setMDC(problemDetail); + log.info("handle info level exception"); + clearMDC(); + } + + private static void setMDC(ProblemDetail problemDetail) { + Map details = problemDetail.getProperties(); + Map map = new HashMap<>(); + for (Entry stringObjectEntry : details.entrySet()) { + map.put(stringObjectEntry.getKey(), java.lang.String.valueOf(stringObjectEntry.getValue())); + } + MDC.setContextMap(map); + } + + public static void warn(ProblemDetail problemDetail) { + setMDC(problemDetail); + log.warn("handle warn level exception"); + clearMDC(); + } + + public static void error(ProblemDetail problemDetail) { + setMDC(problemDetail); + log.error("handle error level exception"); + clearMDC(); + } +} diff --git a/backend/src/main/java/com/cruru/member/controller/MemberController.java b/backend/src/main/java/com/cruru/member/controller/MemberController.java new file mode 100644 index 000000000..7e2cbc188 --- /dev/null +++ b/backend/src/main/java/com/cruru/member/controller/MemberController.java @@ -0,0 +1,26 @@ +package com.cruru.member.controller; + +import com.cruru.member.controller.request.MemberCreateRequest; +import com.cruru.member.facade.MemberFacade; +import jakarta.validation.Valid; +import java.net.URI; +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 +@RequestMapping("/v1/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberFacade memberFacade; + + @PostMapping("/signup") + public ResponseEntity create(@RequestBody @Valid MemberCreateRequest request) { + long memberId = memberFacade.create(request); + return ResponseEntity.created(URI.create("/v1/members/" + memberId)).build(); + } +} diff --git a/backend/src/main/java/com/cruru/member/controller/request/MemberCreateRequest.java b/backend/src/main/java/com/cruru/member/controller/request/MemberCreateRequest.java new file mode 100644 index 000000000..9848d571b --- /dev/null +++ b/backend/src/main/java/com/cruru/member/controller/request/MemberCreateRequest.java @@ -0,0 +1,21 @@ +package com.cruru.member.controller.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record MemberCreateRequest( + @NotBlank(message = "단체명을 입력해주세요.") + String clubName, + + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일의 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "비밀번호를 입력해주세요.") + String password, + + @NotBlank(message = "전화번호를 입력해주세요.") + String phone +) { + +} diff --git a/backend/src/main/java/com/cruru/member/domain/Member.java b/backend/src/main/java/com/cruru/member/domain/Member.java new file mode 100644 index 000000000..a813eba05 --- /dev/null +++ b/backend/src/main/java/com/cruru/member/domain/Member.java @@ -0,0 +1,90 @@ +package com.cruru.member.domain; + +import static com.cruru.member.domain.MemberRole.CLUB_OWNER; + +import com.cruru.BaseEntity; +import com.cruru.member.exception.badrequest.MemberIllegalPhoneNumberException; +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 java.util.Objects; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Member extends BaseEntity { + + private static final Pattern VALID_PHONE_NUMBER_PATTERN = Pattern.compile( + "^(010)\\d{3,4}\\d{4}$|^(02|0[3-6][1-5])\\d{3,4}\\d{4}$"); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Column(unique = true) + private String email; + + private String password; + + private String phone; + + @Column(columnDefinition = "varchar") + @Enumerated(EnumType.STRING) + private MemberRole role; + + public Member(Long id, String email, String password, String phone) { + this(email, password, phone); + this.id = id; + } + + public Member(String email, String password, String phone) { + validatePhoneNumber(phone); + this.email = email; + this.password = password; + this.phone = phone; + this.role = CLUB_OWNER; + } + + private void validatePhoneNumber(String phoneNumber) { + if (!VALID_PHONE_NUMBER_PATTERN.matcher(phoneNumber).matches()) { + throw new MemberIllegalPhoneNumberException(); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Member member)) { + return false; + } + return Objects.equals(id, member.id) && Objects.equals(email, member.email); + } + + @Override + public int hashCode() { + return Objects.hash(id, email); + } + + @Override + public String toString() { + return "Member{" + + "id=" + id + + ", email='" + email + '\'' + + ", password='" + password + '\'' + + ", phoneNumber='" + phone + '\'' + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/member/domain/MemberRole.java b/backend/src/main/java/com/cruru/member/domain/MemberRole.java new file mode 100644 index 000000000..be5d33619 --- /dev/null +++ b/backend/src/main/java/com/cruru/member/domain/MemberRole.java @@ -0,0 +1,8 @@ +package com.cruru.member.domain; + +public enum MemberRole { + + ADMIN, + CLUB_OWNER, + ; +} diff --git a/backend/src/main/java/com/cruru/member/domain/repository/MemberRepository.java b/backend/src/main/java/com/cruru/member/domain/repository/MemberRepository.java new file mode 100644 index 000000000..b8422d94e --- /dev/null +++ b/backend/src/main/java/com/cruru/member/domain/repository/MemberRepository.java @@ -0,0 +1,12 @@ +package com.cruru.member.domain.repository; + +import com.cruru.member.domain.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + + boolean existsByEmail(String email); + + Optional findByEmail(String email); +} diff --git a/backend/src/main/java/com/cruru/member/exception/MemberEmailDuplicatedException.java b/backend/src/main/java/com/cruru/member/exception/MemberEmailDuplicatedException.java new file mode 100644 index 000000000..3e0e55887 --- /dev/null +++ b/backend/src/main/java/com/cruru/member/exception/MemberEmailDuplicatedException.java @@ -0,0 +1,12 @@ +package com.cruru.member.exception; + +import com.cruru.advice.ConflictException; + +public class MemberEmailDuplicatedException extends ConflictException { + + private static final String MESSAGE = "이미 가입되어있는 Email 입니다."; + + public MemberEmailDuplicatedException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/member/exception/MemberNotFoundException.java b/backend/src/main/java/com/cruru/member/exception/MemberNotFoundException.java new file mode 100644 index 000000000..53492d19a --- /dev/null +++ b/backend/src/main/java/com/cruru/member/exception/MemberNotFoundException.java @@ -0,0 +1,12 @@ +package com.cruru.member.exception; + +import com.cruru.advice.NotFoundException; + +public class MemberNotFoundException extends NotFoundException { + + private static final String TARGET = "회원"; + + public MemberNotFoundException() { + super(TARGET); + } +} diff --git a/backend/src/main/java/com/cruru/member/exception/badrequest/MemberIllegalPasswordException.java b/backend/src/main/java/com/cruru/member/exception/badrequest/MemberIllegalPasswordException.java new file mode 100644 index 000000000..27d61ff10 --- /dev/null +++ b/backend/src/main/java/com/cruru/member/exception/badrequest/MemberIllegalPasswordException.java @@ -0,0 +1,12 @@ +package com.cruru.member.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class MemberIllegalPasswordException extends BadRequestException { + + private static final String MESSAGE = "입력하신 비밀번호의 형식이 맞지 않습니다."; + + public MemberIllegalPasswordException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/member/exception/badrequest/MemberIllegalPhoneNumberException.java b/backend/src/main/java/com/cruru/member/exception/badrequest/MemberIllegalPhoneNumberException.java new file mode 100644 index 000000000..0ab662945 --- /dev/null +++ b/backend/src/main/java/com/cruru/member/exception/badrequest/MemberIllegalPhoneNumberException.java @@ -0,0 +1,12 @@ +package com.cruru.member.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class MemberIllegalPhoneNumberException extends BadRequestException { + + private static final String MESSAGE = "올바르지 않은 전화번호 형식입니다."; + + public MemberIllegalPhoneNumberException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/member/exception/badrequest/MemberPasswordLengthException.java b/backend/src/main/java/com/cruru/member/exception/badrequest/MemberPasswordLengthException.java new file mode 100644 index 000000000..799c51eb6 --- /dev/null +++ b/backend/src/main/java/com/cruru/member/exception/badrequest/MemberPasswordLengthException.java @@ -0,0 +1,12 @@ +package com.cruru.member.exception.badrequest; + +import com.cruru.advice.badrequest.TextLengthException; + +public class MemberPasswordLengthException extends TextLengthException { + + private static final String TEXT = "비밀번호"; + + public MemberPasswordLengthException(int min, int max, int currentLength) { + super(TEXT, min, max, currentLength); + } +} diff --git a/backend/src/main/java/com/cruru/member/facade/MemberFacade.java b/backend/src/main/java/com/cruru/member/facade/MemberFacade.java new file mode 100644 index 000000000..e0f02a956 --- /dev/null +++ b/backend/src/main/java/com/cruru/member/facade/MemberFacade.java @@ -0,0 +1,25 @@ +package com.cruru.member.facade; + +import com.cruru.club.service.ClubService; +import com.cruru.member.controller.request.MemberCreateRequest; +import com.cruru.member.domain.Member; +import com.cruru.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MemberFacade { + + private final MemberService memberService; + private final ClubService clubService; + + @Transactional + public long create(MemberCreateRequest request) { + Member savedMember = memberService.create(request); + clubService.create(request.clubName(), savedMember); + return savedMember.getId(); + } +} diff --git a/backend/src/main/java/com/cruru/member/service/MemberService.java b/backend/src/main/java/com/cruru/member/service/MemberService.java new file mode 100644 index 000000000..09785871d --- /dev/null +++ b/backend/src/main/java/com/cruru/member/service/MemberService.java @@ -0,0 +1,65 @@ +package com.cruru.member.service; + +import com.cruru.auth.security.PasswordValidator; +import com.cruru.member.controller.request.MemberCreateRequest; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.member.exception.MemberEmailDuplicatedException; +import com.cruru.member.exception.MemberNotFoundException; +import com.cruru.member.exception.badrequest.MemberIllegalPasswordException; +import com.cruru.member.exception.badrequest.MemberPasswordLengthException; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MemberService { + + private static final int PASSWORD_MIN_LENGTH = 8; + private static final int PASSWORD_MAX_LENGTH = 32; + private static final Pattern VALID_PASSWORD_PATTERN = Pattern.compile( + "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).*$"); + + private final MemberRepository memberRepository; + private final PasswordValidator passwordValidator; + + @Transactional + public Member create(MemberCreateRequest request) { + boolean exists = memberRepository.existsByEmail(request.email()); + if (exists) { + throw new MemberEmailDuplicatedException(); + } + + String encodedPassword = generateEncodedPassword(request); + Member newMember = new Member(request.email(), encodedPassword, request.phone()); + return memberRepository.save(newMember); + } + + private String generateEncodedPassword(MemberCreateRequest request) { + String rawPassword = request.password(); + validatePassword(rawPassword); + return passwordValidator.encode(rawPassword); + } + + private void validatePassword(String rawPassword) { + if (rawPassword.length() < PASSWORD_MIN_LENGTH || rawPassword.length() > PASSWORD_MAX_LENGTH) { + throw new MemberPasswordLengthException(PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, rawPassword.length()); + } + if (!VALID_PASSWORD_PATTERN.matcher(rawPassword).matches()) { + throw new MemberIllegalPasswordException(); + } + } + + public Member findById(Long id) { + return memberRepository.findById(id) + .orElseThrow(MemberNotFoundException::new); + } + + public Member findByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + } +} diff --git a/backend/src/main/java/com/cruru/process/controller/ProcessController.java b/backend/src/main/java/com/cruru/process/controller/ProcessController.java new file mode 100644 index 000000000..6a8161a9d --- /dev/null +++ b/backend/src/main/java/com/cruru/process/controller/ProcessController.java @@ -0,0 +1,70 @@ +package com.cruru.process.controller; + +import com.cruru.auth.annotation.RequireAuthCheck; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.global.LoginProfile; +import com.cruru.process.controller.request.ProcessCreateRequest; +import com.cruru.process.controller.request.ProcessUpdateRequest; +import com.cruru.process.controller.response.ProcessResponse; +import com.cruru.process.controller.response.ProcessResponses; +import com.cruru.process.domain.Process; +import com.cruru.process.facade.ProcessFacade; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/processes") +@RequiredArgsConstructor +public class ProcessController { + + private final ProcessFacade processFacade; + + @RequireAuthCheck(targetId = "dashboardId", targetDomain = Dashboard.class) + @GetMapping + public ResponseEntity read( + LoginProfile loginProfile, @RequestParam(name = "dashboardId") Long dashboardId + ) { + ProcessResponses processes = processFacade.readAllByDashboardId(dashboardId); + return ResponseEntity.ok().body(processes); + } + + @RequireAuthCheck(targetId = "dashboardId", targetDomain = Dashboard.class) + @PostMapping + public ResponseEntity create( + LoginProfile loginProfile, + @RequestParam(name = "dashboardId") Long dashboardId, + @RequestBody @Valid ProcessCreateRequest request + ) { + processFacade.create(request, dashboardId); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @RequireAuthCheck(targetId = "processId", targetDomain = Process.class) + @PatchMapping("/{processId}") + public ResponseEntity update( + LoginProfile loginProfile, + @PathVariable Long processId, + @RequestBody @Valid ProcessUpdateRequest request + ) { + ProcessResponse response = processFacade.update(request, processId); + return ResponseEntity.ok().body(response); + } + + @RequireAuthCheck(targetId = "processId", targetDomain = Process.class) + @DeleteMapping("/{processId}") + public ResponseEntity delete(LoginProfile loginProfile, @PathVariable Long processId) { + processFacade.delete(processId); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/cruru/process/controller/request/ProcessCreateRequest.java b/backend/src/main/java/com/cruru/process/controller/request/ProcessCreateRequest.java new file mode 100644 index 000000000..c6e82bcf7 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/controller/request/ProcessCreateRequest.java @@ -0,0 +1,22 @@ +package com.cruru.process.controller.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +public record ProcessCreateRequest( + @NotBlank(message = "프로세스 이름은 필수 값입니다.") + @JsonProperty("processName") + String name, + + @NotBlank(message = "프로세스 설명은 필수 값입니다.") + String description, + + @NotNull(message = "프로세스 순서는 필수 값입니다.") + @PositiveOrZero(message = "프로세스 순서는 0 이상의 정수입니다.") + @JsonProperty("orderIndex") + Integer sequence +) { + +} diff --git a/backend/src/main/java/com/cruru/process/controller/request/ProcessUpdateRequest.java b/backend/src/main/java/com/cruru/process/controller/request/ProcessUpdateRequest.java new file mode 100644 index 000000000..c74fec47d --- /dev/null +++ b/backend/src/main/java/com/cruru/process/controller/request/ProcessUpdateRequest.java @@ -0,0 +1,16 @@ +package com.cruru.process.controller.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ProcessUpdateRequest( + @NotBlank(message = "수정할 프로세스 이름은 필수 값입니다.") + @JsonProperty("processName") + String name, + + @NotNull(message = "수정할 프로세스 설명은 필수 값입니다.") + String description +) { + +} diff --git a/backend/src/main/java/com/cruru/process/controller/response/ProcessResponse.java b/backend/src/main/java/com/cruru/process/controller/response/ProcessResponse.java new file mode 100644 index 000000000..12381a4a1 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/controller/response/ProcessResponse.java @@ -0,0 +1,22 @@ +package com.cruru.process.controller.response; + +import com.cruru.applicant.controller.response.ApplicantCardResponse; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record ProcessResponse( + @JsonProperty("processId") + long id, + + @JsonProperty("orderIndex") + int sequence, + + String name, + + String description, + + @JsonProperty("applicants") + List applicantCardResponses +) { + +} diff --git a/backend/src/main/java/com/cruru/process/controller/response/ProcessResponses.java b/backend/src/main/java/com/cruru/process/controller/response/ProcessResponses.java new file mode 100644 index 000000000..15ac84f0e --- /dev/null +++ b/backend/src/main/java/com/cruru/process/controller/response/ProcessResponses.java @@ -0,0 +1,16 @@ +package com.cruru.process.controller.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record ProcessResponses( + + long applyFormId, + + @JsonProperty("processes") + List processResponses, + + String title +) { + +} diff --git a/backend/src/main/java/com/cruru/process/controller/response/ProcessSimpleResponse.java b/backend/src/main/java/com/cruru/process/controller/response/ProcessSimpleResponse.java new file mode 100644 index 000000000..54009bf5f --- /dev/null +++ b/backend/src/main/java/com/cruru/process/controller/response/ProcessSimpleResponse.java @@ -0,0 +1,8 @@ +package com.cruru.process.controller.response; + +public record ProcessSimpleResponse( + long id, + String name +) { + +} diff --git a/backend/src/main/java/com/cruru/process/domain/Process.java b/backend/src/main/java/com/cruru/process/domain/Process.java new file mode 100644 index 000000000..2355ad03f --- /dev/null +++ b/backend/src/main/java/com/cruru/process/domain/Process.java @@ -0,0 +1,146 @@ +package com.cruru.process.domain; + +import com.cruru.auth.util.SecureResource; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.member.domain.Member; +import com.cruru.process.exception.badrequest.ProcessNameBlankException; +import com.cruru.process.exception.badrequest.ProcessNameCharacterException; +import com.cruru.process.exception.badrequest.ProcessNameLengthException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Process implements SecureResource { + + private static final int MAX_NAME_LENGTH = 32; + private static final Pattern NAME_PATTERN = Pattern.compile("^[^\\\\|]*$"); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "process_id") + private Long id; + + private Integer sequence; + + private String name; + + private String description; + + @Column(columnDefinition = "varchar") + @Enumerated(EnumType.STRING) + private ProcessType type; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "dashboard_id") + private Dashboard dashboard; + + public Process(int sequence, String name, String description, ProcessType type, Dashboard dashboard) { + validateName(name); + this.sequence = sequence; + this.name = name; + this.description = description; + this.type = type; + this.dashboard = dashboard; + } + + private void validateName(String name) { + if (name.isBlank()) { + throw new ProcessNameBlankException(); + } + if (isLengthOutOfRange(name)) { + throw new ProcessNameLengthException(MAX_NAME_LENGTH, name.length()); + } + if (isContainingInvalidCharacter(name)) { + String invalidCharacters = Stream.of(NAME_PATTERN.matcher(name).replaceAll("").split("")) + .distinct() + .collect(Collectors.joining(", ")); + throw new ProcessNameCharacterException(invalidCharacters); + } + } + + private boolean isLengthOutOfRange(String name) { + return name.length() > MAX_NAME_LENGTH; + } + + private boolean isContainingInvalidCharacter(String name) { + return !NAME_PATTERN.matcher(name).matches(); + } + + public boolean isApplyType() { + return type == ProcessType.APPLY; + } + + public boolean isApproveType() { + return type == ProcessType.APPROVE; + } + + public boolean isFixed() { + return type.isFixed(); + } + + public void updateName(String name) { + validateName(name); + this.name = name; + } + + public void updateDescription(String description) { + this.description = description; + } + + public void increaseSequenceNumber() { + this.sequence++; + } + + @Override + public boolean isAuthorizedBy(Member member) { + return dashboard.isAuthorizedBy(member); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Process process = (Process) o; + return Objects.equals(id, process.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "Process{" + + "id=" + id + + ", sequence=" + sequence + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", type=" + type + + ", dashboard=" + dashboard + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/process/domain/ProcessFactory.java b/backend/src/main/java/com/cruru/process/domain/ProcessFactory.java new file mode 100644 index 000000000..47c7f86a9 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/domain/ProcessFactory.java @@ -0,0 +1,31 @@ +package com.cruru.process.domain; + +import com.cruru.dashboard.domain.Dashboard; +import java.util.ArrayList; +import java.util.List; + +public class ProcessFactory { + + private static final int FIRST_SEQUENCE = 0; + private static final String FIRST_PROCESS_NAME = "지원서 접수"; + private static final String FIRST_PROCESS_DESCRIPTION = "지원서를 제출하는 단계"; + private static final int LAST_SEQUENCE = 1; + private static final String LAST_PROCESS_NAME = "최종 결과"; + private static final String LAST_PROCESS_DESCRIPTION = "최종 합격자 선별 단계"; + + private ProcessFactory() { + throw new IllegalStateException("유틸리티 클래스를 인스턴스를 생성할 수 없습니다."); + } + + public static List createInitProcesses(Dashboard dashboard) { + return new ArrayList<>(List.of(createFirstOf(dashboard), createLastOf(dashboard))); + } + + private static Process createFirstOf(Dashboard dashboard) { + return new Process(FIRST_SEQUENCE, FIRST_PROCESS_NAME, FIRST_PROCESS_DESCRIPTION, ProcessType.APPLY, dashboard); + } + + private static Process createLastOf(Dashboard dashboard) { + return new Process(LAST_SEQUENCE, LAST_PROCESS_NAME, LAST_PROCESS_DESCRIPTION, ProcessType.APPROVE, dashboard); + } +} diff --git a/backend/src/main/java/com/cruru/process/domain/ProcessType.java b/backend/src/main/java/com/cruru/process/domain/ProcessType.java new file mode 100644 index 000000000..4ab28bf92 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/domain/ProcessType.java @@ -0,0 +1,17 @@ +package com.cruru.process.domain; + +import lombok.Getter; + +@Getter +public enum ProcessType { + APPLY(true), + EVALUATE(false), + APPROVE(true), + ; + + private final boolean fixed; + + ProcessType(boolean fixed) { + this.fixed = fixed; + } +} diff --git a/backend/src/main/java/com/cruru/process/domain/repository/ProcessRepository.java b/backend/src/main/java/com/cruru/process/domain/repository/ProcessRepository.java new file mode 100644 index 000000000..7dbd2a16c --- /dev/null +++ b/backend/src/main/java/com/cruru/process/domain/repository/ProcessRepository.java @@ -0,0 +1,15 @@ +package com.cruru.process.domain.repository; + +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.process.domain.Process; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProcessRepository extends JpaRepository { + + List findAllByDashboardId(long dashboardId); + + int countByDashboard(Dashboard dashboard); + + List findAllByDashboard(Dashboard dashboard); +} diff --git a/backend/src/main/java/com/cruru/process/exception/ProcessNotFoundException.java b/backend/src/main/java/com/cruru/process/exception/ProcessNotFoundException.java new file mode 100644 index 000000000..7eca18e47 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/exception/ProcessNotFoundException.java @@ -0,0 +1,12 @@ +package com.cruru.process.exception; + +import com.cruru.advice.NotFoundException; + +public class ProcessNotFoundException extends NotFoundException { + + private static final String TARGET = "프로세스"; + + public ProcessNotFoundException() { + super(TARGET); + } +} diff --git a/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessCountException.java b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessCountException.java new file mode 100644 index 000000000..5b85ba657 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessCountException.java @@ -0,0 +1,12 @@ +package com.cruru.process.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ProcessCountException extends BadRequestException { + + private static final String MESSAGE = "프로세스는 최대 %d개까지 생성 가능합니다."; + + public ProcessCountException(int maxCount) { + super(String.format(MESSAGE, maxCount)); + } +} diff --git a/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessDeleteFixedException.java b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessDeleteFixedException.java new file mode 100644 index 000000000..ba2d6818d --- /dev/null +++ b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessDeleteFixedException.java @@ -0,0 +1,12 @@ +package com.cruru.process.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ProcessDeleteFixedException extends BadRequestException { + + private static final String MESSAGE = "삭제가 불가능한 프로세스입니다."; + + public ProcessDeleteFixedException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessDeleteRemainingApplicantException.java b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessDeleteRemainingApplicantException.java new file mode 100644 index 000000000..9194436a1 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessDeleteRemainingApplicantException.java @@ -0,0 +1,12 @@ +package com.cruru.process.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ProcessDeleteRemainingApplicantException extends BadRequestException { + + private static final String MESSAGE = "지원자가 존재하는 프로세스는 삭제할 수 없습니다."; + + public ProcessDeleteRemainingApplicantException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameBlankException.java b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameBlankException.java new file mode 100644 index 000000000..3db752c07 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameBlankException.java @@ -0,0 +1,12 @@ +package com.cruru.process.exception.badrequest; + +import com.cruru.advice.badrequest.TextBlankException; + +public class ProcessNameBlankException extends TextBlankException { + + private static final String TEXT = "프로세스 이름"; + + public ProcessNameBlankException() { + super(TEXT); + } +} diff --git a/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameCharacterException.java b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameCharacterException.java new file mode 100644 index 000000000..33872ebf9 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameCharacterException.java @@ -0,0 +1,12 @@ +package com.cruru.process.exception.badrequest; + +import com.cruru.advice.badrequest.TextCharacterException; + +public class ProcessNameCharacterException extends TextCharacterException { + + private static final String TEXT = "프로세스 이름"; + + public ProcessNameCharacterException(String invalidCharacters) { + super(TEXT, invalidCharacters); + } +} diff --git a/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameLengthException.java b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameLengthException.java new file mode 100644 index 000000000..0766d793a --- /dev/null +++ b/backend/src/main/java/com/cruru/process/exception/badrequest/ProcessNameLengthException.java @@ -0,0 +1,12 @@ +package com.cruru.process.exception.badrequest; + +import com.cruru.advice.badrequest.TextLengthException; + +public class ProcessNameLengthException extends TextLengthException { + + private static final String TEXT = "프로세스 이름"; + + public ProcessNameLengthException(int maxLength, int currentLength) { + super(TEXT, maxLength, currentLength); + } +} diff --git a/backend/src/main/java/com/cruru/process/facade/ProcessFacade.java b/backend/src/main/java/com/cruru/process/facade/ProcessFacade.java new file mode 100644 index 000000000..f4a107237 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/facade/ProcessFacade.java @@ -0,0 +1,94 @@ +package com.cruru.process.facade; + +import com.cruru.applicant.controller.response.ApplicantCardResponse; +import com.cruru.applicant.domain.dto.ApplicantCard; +import com.cruru.applicant.service.ApplicantService; +import com.cruru.applicant.service.EvaluationService; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.service.ApplyFormService; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.service.DashboardService; +import com.cruru.process.controller.request.ProcessCreateRequest; +import com.cruru.process.controller.request.ProcessUpdateRequest; +import com.cruru.process.controller.response.ProcessResponse; +import com.cruru.process.controller.response.ProcessResponses; +import com.cruru.process.domain.Process; +import com.cruru.process.service.ProcessService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ProcessFacade { + + private final ProcessService processService; + private final DashboardService dashboardService; + private final ApplicantService applicantService; + private final EvaluationService evaluationService; + private final ApplyFormService applyFormService; + + @Transactional + public void create(ProcessCreateRequest request, long dashboardId) { + Dashboard dashboard = dashboardService.findById(dashboardId); + processService.create(request, dashboard); + } + + public ProcessResponses readAllByDashboardId(long dashboardId) { + ApplyForm applyForm = applyFormService.findByDashboardId(dashboardId); + List processes = processService.findAllByDashboard(dashboardId); + List applicantCards = applicantService.findApplicantCards(processes); + + List processResponses = processes.stream() + .map(process -> toProcessResponse(process, applicantCards)) + .toList(); + + return new ProcessResponses(applyForm.getId(), processResponses, applyForm.getTitle()); + } + + private ProcessResponse toProcessResponse(Process process, List applicantCards) { + List applicantCardResponses = applicantCards.stream() + .filter(card -> card.processId() == process.getId()) + .map(ApplicantCard::toResponse) + .toList(); + + return new ProcessResponse( + process.getId(), + process.getSequence(), + process.getName(), + process.getDescription(), + applicantCardResponses + ); + } + + @Transactional + public ProcessResponse update(ProcessUpdateRequest request, long processId) { + Process process = processService.findById(processId); + + processService.update(request, process.getId()); + return toProcessResponse(process); + } + + private ProcessResponse toProcessResponse(Process process) { + List applicantCardResponses = applicantService.findApplicantCards(process) + .stream() + .map(ApplicantCard::toResponse) + .toList(); + + return new ProcessResponse( + process.getId(), + process.getSequence(), + process.getName(), + process.getDescription(), + applicantCardResponses + ); + } + + @Transactional + public void delete(long processId) { + evaluationService.deleteByProcess(processId); + processService.delete(processId); + } +} diff --git a/backend/src/main/java/com/cruru/process/service/ProcessService.java b/backend/src/main/java/com/cruru/process/service/ProcessService.java new file mode 100644 index 000000000..23d1a8716 --- /dev/null +++ b/backend/src/main/java/com/cruru/process/service/ProcessService.java @@ -0,0 +1,120 @@ +package com.cruru.process.service; + +import com.cruru.advice.InternalServerException; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.process.controller.request.ProcessCreateRequest; +import com.cruru.process.controller.request.ProcessUpdateRequest; +import com.cruru.process.controller.response.ProcessSimpleResponse; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.ProcessType; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.process.exception.ProcessNotFoundException; +import com.cruru.process.exception.badrequest.ProcessCountException; +import com.cruru.process.exception.badrequest.ProcessDeleteFixedException; +import com.cruru.process.exception.badrequest.ProcessDeleteRemainingApplicantException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ProcessService { + + private static final int MAX_PROCESS_COUNT = 5; + private static final int ZERO = 0; + + private final ApplicantRepository applicantRepository; + private final ProcessRepository processRepository; + + public ProcessSimpleResponse toProcessSimpleResponse(Process process) { + return new ProcessSimpleResponse(process.getId(), process.getName()); + } + + @Transactional + public void create(ProcessCreateRequest request, Dashboard dashboard) { + validateProcessCount(dashboard); + List processes = findAllByDashboard(dashboard); + + rearrangeProcesses(request.sequence(), processes); + + processRepository.save(toEvaluateProcess(request, dashboard)); + } + + private void validateProcessCount(Dashboard dashboard) { + int size = processRepository.countByDashboard(dashboard); + if (size >= MAX_PROCESS_COUNT) { + throw new ProcessCountException(MAX_PROCESS_COUNT); + } + } + + public List findAllByDashboard(Dashboard dashboard) { + return processRepository.findAllByDashboard(dashboard); + } + + private void rearrangeProcesses(int newProcessSequence, List processes) { + processes.stream() + .filter(process -> process.getSequence() >= newProcessSequence) + .forEach(Process::increaseSequenceNumber); + } + + private Process toEvaluateProcess(ProcessCreateRequest request, Dashboard dashboard) { + return new Process(request.sequence(), request.name(), request.description(), ProcessType.EVALUATE, dashboard); + } + + public List findAllByDashboard(Long dashboardId) { + return processRepository.findAllByDashboardId(dashboardId); + } + + public Process findApplyProcessOnDashboard(Dashboard dashboard) { + List processes = findAllByDashboard(dashboard); + return processes.stream() + .filter(Process::isApplyType) + .findFirst() + .orElseThrow(InternalServerException::new); + } + + @Transactional + public Process update(ProcessUpdateRequest request, long processId) { + Process process = findById(processId); + + if (changeExists(request, process)) { + process.updateName(request.name()); + process.updateDescription(request.description()); + } + + return process; + } + + public Process findById(Long processId) { + return processRepository.findById(processId) + .orElseThrow(ProcessNotFoundException::new); + } + + private boolean changeExists(ProcessUpdateRequest request, Process process) { + return !(request.name().equals(process.getName()) && request.description().equals(process.getDescription())); + } + + @Transactional + public void delete(long processId) { + Process process = findById(processId); + validateFixedProcess(process); + validateApplicantRemains(process); + processRepository.deleteById(processId); + } + + private void validateFixedProcess(Process process) { + if (process.isFixed()) { + throw new ProcessDeleteFixedException(); + } + } + + private void validateApplicantRemains(Process process) { + long applicantCount = applicantRepository.countByProcess(process); + if (applicantCount > ZERO) { + throw new ProcessDeleteRemainingApplicantException(); + } + } +} diff --git a/backend/src/main/java/com/cruru/question/controller/QuestionController.java b/backend/src/main/java/com/cruru/question/controller/QuestionController.java new file mode 100644 index 000000000..bf84b1076 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/controller/QuestionController.java @@ -0,0 +1,29 @@ +package com.cruru.question.controller; + +import com.cruru.question.controller.request.QuestionUpdateRequests; +import com.cruru.question.facade.QuestionFacade; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/questions") +@RequiredArgsConstructor +public class QuestionController { + + private final QuestionFacade questionFacade; + + @PatchMapping + public ResponseEntity update( + @RequestParam(name = "applyformId") Long applyFormId, + @RequestBody @Valid QuestionUpdateRequests request + ) { + questionFacade.update(request, applyFormId); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/cruru/question/controller/request/ChoiceCreateRequest.java b/backend/src/main/java/com/cruru/question/controller/request/ChoiceCreateRequest.java new file mode 100644 index 000000000..9e19bcba3 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/controller/request/ChoiceCreateRequest.java @@ -0,0 +1,16 @@ +package com.cruru.question.controller.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +public record ChoiceCreateRequest( + @NotBlank(message = "객관식 질문의 선택지는 필수 값입니다.") + String choice, + + @NotNull(message = "객관식 질문의 순서는 필수 값입니다.") + @PositiveOrZero(message = "객관식 질문의 순서는 0이거나 양의 정수여야 합니다.") + Integer orderIndex +) { + +} diff --git a/backend/src/main/java/com/cruru/question/controller/request/QuestionCreateRequest.java b/backend/src/main/java/com/cruru/question/controller/request/QuestionCreateRequest.java new file mode 100644 index 000000000..f11129ce7 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/controller/request/QuestionCreateRequest.java @@ -0,0 +1,27 @@ +package com.cruru.question.controller.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; + +public record QuestionCreateRequest( + @NotBlank(message = "질문 유형은 필수 값입니다.") + String type, + + @NotBlank(message = "질문 내용은 필수 값입니다.") + String question, + + @Valid + List choices, + + @NotNull + @PositiveOrZero + Integer orderIndex, + + @NotNull + Boolean required +) { + +} diff --git a/backend/src/main/java/com/cruru/question/controller/request/QuestionUpdateRequests.java b/backend/src/main/java/com/cruru/question/controller/request/QuestionUpdateRequests.java new file mode 100644 index 000000000..b7725836e --- /dev/null +++ b/backend/src/main/java/com/cruru/question/controller/request/QuestionUpdateRequests.java @@ -0,0 +1,13 @@ +package com.cruru.question.controller.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import java.util.List; + +public record QuestionUpdateRequests( + @JsonProperty("questions") + @Valid + List questions +) { + +} diff --git a/backend/src/main/java/com/cruru/question/controller/response/AnswerResponse.java b/backend/src/main/java/com/cruru/question/controller/response/AnswerResponse.java new file mode 100644 index 000000000..ce56be9ae --- /dev/null +++ b/backend/src/main/java/com/cruru/question/controller/response/AnswerResponse.java @@ -0,0 +1,14 @@ +package com.cruru.question.controller.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AnswerResponse( + @JsonProperty("orderIndex") + int sequence, + + String question, + + String answer +) { + +} diff --git a/backend/src/main/java/com/cruru/question/controller/response/ChoiceResponse.java b/backend/src/main/java/com/cruru/question/controller/response/ChoiceResponse.java new file mode 100644 index 000000000..1e8e17dae --- /dev/null +++ b/backend/src/main/java/com/cruru/question/controller/response/ChoiceResponse.java @@ -0,0 +1,14 @@ +package com.cruru.question.controller.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record ChoiceResponse( + long id, + + @JsonProperty("label") + String content, + + int orderIndex +) { + +} diff --git a/backend/src/main/java/com/cruru/question/controller/response/QuestionResponse.java b/backend/src/main/java/com/cruru/question/controller/response/QuestionResponse.java new file mode 100644 index 000000000..f901c957c --- /dev/null +++ b/backend/src/main/java/com/cruru/question/controller/response/QuestionResponse.java @@ -0,0 +1,22 @@ +package com.cruru.question.controller.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record QuestionResponse( + long id, + + String type, + + @JsonProperty("label") + String content, + + int orderIndex, + + @JsonProperty("choices") + List choiceResponses, + + boolean required +) { + +} diff --git a/backend/src/main/java/com/cruru/question/domain/Answer.java b/backend/src/main/java/com/cruru/question/domain/Answer.java new file mode 100644 index 000000000..cda1e9eef --- /dev/null +++ b/backend/src/main/java/com/cruru/question/domain/Answer.java @@ -0,0 +1,100 @@ +package com.cruru.question.domain; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.auth.util.SecureResource; +import com.cruru.member.domain.Member; +import com.cruru.question.exception.AnswerContentLengthException; +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.util.Objects; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Answer implements SecureResource { + + private static final int SHORT_ANSWER_MAX_LENGTH = 50; + private static final int LONG_ANSWER_MAX_LENGTH = 1000; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "answer_id") + private Long id; + + @Column(columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id") + private Question question; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "applicant_id") + private Applicant applicant; + + public Answer(String content, Question question, Applicant applicant) { + validateContentLengthByQuestionType(content, question); + this.content = content; + this.question = question; + this.applicant = applicant; + } + + private void validateContentLengthByQuestionType(String content, Question question) { + if (question.isShortAnswer()) { + validateContentLength(SHORT_ANSWER_MAX_LENGTH, content.length()); + } + + if (question.isLongAnswer()) { + validateContentLength(LONG_ANSWER_MAX_LENGTH, content.length()); + } + } + + private void validateContentLength(int maxLength, int currentLength) { + if (currentLength > maxLength) { + throw new AnswerContentLengthException(maxLength, currentLength); + } + } + + @Override + public boolean isAuthorizedBy(Member member) { + return applicant.isAuthorizedBy(member); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Answer answer = (Answer) o; + return Objects.equals(id, answer.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "Answer{" + + "id=" + id + + ", content='" + content + '\'' + + ", question=" + question + + ", applicant=" + applicant + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/question/domain/Choice.java b/backend/src/main/java/com/cruru/question/domain/Choice.java new file mode 100644 index 000000000..3c6af67f6 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/domain/Choice.java @@ -0,0 +1,74 @@ +package com.cruru.question.domain; + +import com.cruru.auth.util.SecureResource; +import com.cruru.member.domain.Member; +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.util.Objects; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Choice implements SecureResource { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "choice_id") + private Long id; + + private String content; + + private Integer sequence; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id") + private Question question; + + public Choice(String content, Integer sequence, Question question) { + this.content = content; + this.sequence = sequence; + this.question = question; + } + + @Override + public boolean isAuthorizedBy(Member member) { + return question.isAuthorizedBy(member); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Choice choice = (Choice) o; + return Objects.equals(id, choice.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "Choice{" + + "id=" + id + + ", content='" + content + '\'' + + ", question=" + question + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/question/domain/Question.java b/backend/src/main/java/com/cruru/question/domain/Question.java new file mode 100644 index 000000000..5f6752a76 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/domain/Question.java @@ -0,0 +1,105 @@ +package com.cruru.question.domain; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.auth.util.SecureResource; +import com.cruru.member.domain.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class Question implements SecureResource { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "question_id") + private Long id; + + @Column(columnDefinition = "varchar") + @Enumerated(EnumType.STRING) + private QuestionType questionType; + + private String content; + + private Integer sequence; + + private boolean required; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "apply_form_id") + private ApplyForm applyForm; + + public Question( + QuestionType questionType, + String content, + Integer sequence, + Boolean required, + ApplyForm applyForm + ) { + this.questionType = questionType; + this.content = content; + this.sequence = sequence; + this.required = required; + this.applyForm = applyForm; + } + + public boolean hasChoice() { + return questionType.hasChoice(); + } + + public boolean isShortAnswer() { + return questionType == QuestionType.SHORT_ANSWER; + } + + public boolean isLongAnswer() { + return questionType == QuestionType.LONG_ANSWER; + } + + @Override + public boolean isAuthorizedBy(Member member) { + return applyForm.isAuthorizedBy(member); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Question question = (Question) o; + return Objects.equals(id, question.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "Question{" + + "id=" + id + + ", questionType=" + questionType + + ", content='" + content + '\'' + + ", sequence=" + sequence + + ", applyForm=" + applyForm + + '}'; + } +} diff --git a/backend/src/main/java/com/cruru/question/domain/QuestionType.java b/backend/src/main/java/com/cruru/question/domain/QuestionType.java new file mode 100644 index 000000000..97457e895 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/domain/QuestionType.java @@ -0,0 +1,21 @@ +package com.cruru.question.domain; + +public enum QuestionType { + + SHORT_ANSWER(false), + LONG_ANSWER(false), + DROPDOWN(true), + MULTIPLE_CHOICE(true), + SINGLE_CHOICE(true), + ; + + private final boolean hasChoice; + + QuestionType(boolean hasChoice) { + this.hasChoice = hasChoice; + } + + public boolean hasChoice() { + return hasChoice; + } +} diff --git a/backend/src/main/java/com/cruru/question/domain/repository/AnswerRepository.java b/backend/src/main/java/com/cruru/question/domain/repository/AnswerRepository.java new file mode 100644 index 000000000..e0c608f8a --- /dev/null +++ b/backend/src/main/java/com/cruru/question/domain/repository/AnswerRepository.java @@ -0,0 +1,14 @@ +package com.cruru.question.domain.repository; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.question.domain.Answer; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AnswerRepository extends JpaRepository { + + @Query("SELECT a FROM Answer a JOIN FETCH a.question WHERE a.applicant = :applicant") + List findAllByApplicantWithQuestions(@Param("applicant") Applicant applicant); +} diff --git a/backend/src/main/java/com/cruru/question/domain/repository/ChoiceRepository.java b/backend/src/main/java/com/cruru/question/domain/repository/ChoiceRepository.java new file mode 100644 index 000000000..b93d933d5 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/domain/repository/ChoiceRepository.java @@ -0,0 +1,13 @@ +package com.cruru.question.domain.repository; + +import com.cruru.question.domain.Choice; +import com.cruru.question.domain.Question; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChoiceRepository extends JpaRepository { + + List findAllByQuestion(Question question); + + void deleteAllByQuestion(Question question); +} diff --git a/backend/src/main/java/com/cruru/question/domain/repository/QuestionRepository.java b/backend/src/main/java/com/cruru/question/domain/repository/QuestionRepository.java new file mode 100644 index 000000000..781b63cf6 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/domain/repository/QuestionRepository.java @@ -0,0 +1,11 @@ +package com.cruru.question.domain.repository; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.question.domain.Question; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuestionRepository extends JpaRepository { + + List findAllByApplyForm(ApplyForm applyForm); +} diff --git a/backend/src/main/java/com/cruru/question/exception/AnswerContentLengthException.java b/backend/src/main/java/com/cruru/question/exception/AnswerContentLengthException.java new file mode 100644 index 000000000..d846f6aa3 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/exception/AnswerContentLengthException.java @@ -0,0 +1,12 @@ +package com.cruru.question.exception; + +import com.cruru.advice.badrequest.TextLengthException; + +public class AnswerContentLengthException extends TextLengthException { + + private static final String TEXT = "답변"; + + public AnswerContentLengthException(int maxLength, int currentLength) { + super(TEXT, maxLength, currentLength); + } +} diff --git a/backend/src/main/java/com/cruru/question/exception/QuestionNotFoundException.java b/backend/src/main/java/com/cruru/question/exception/QuestionNotFoundException.java new file mode 100644 index 000000000..f20b6fd5a --- /dev/null +++ b/backend/src/main/java/com/cruru/question/exception/QuestionNotFoundException.java @@ -0,0 +1,12 @@ +package com.cruru.question.exception; + +import com.cruru.advice.NotFoundException; + +public class QuestionNotFoundException extends NotFoundException { + + private static final String TEXT = "질문"; + + public QuestionNotFoundException() { + super(TEXT); + } +} diff --git a/backend/src/main/java/com/cruru/question/exception/QuestionUnmodifiableException.java b/backend/src/main/java/com/cruru/question/exception/QuestionUnmodifiableException.java new file mode 100644 index 000000000..4ea73f2d9 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/exception/QuestionUnmodifiableException.java @@ -0,0 +1,13 @@ +package com.cruru.question.exception; + +import com.cruru.advice.badrequest.BadRequestException; + +public class QuestionUnmodifiableException extends BadRequestException { + + private static final String MESSAGE = "진행중인 모집 공고는 질문을 수정할 수 없습니다."; + + public QuestionUnmodifiableException() { + super(MESSAGE); + } + +} diff --git a/backend/src/main/java/com/cruru/question/exception/badrequest/ChoiceEmptyException.java b/backend/src/main/java/com/cruru/question/exception/badrequest/ChoiceEmptyException.java new file mode 100644 index 000000000..7b7a8a6db --- /dev/null +++ b/backend/src/main/java/com/cruru/question/exception/badrequest/ChoiceEmptyException.java @@ -0,0 +1,12 @@ +package com.cruru.question.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ChoiceEmptyException extends BadRequestException { + + private static final String MESSAGE = "객관식 질문에는 1개 이상의 객관식 선택지를 포함해야합니다."; + + public ChoiceEmptyException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/question/exception/badrequest/ChoiceIllegalSaveException.java b/backend/src/main/java/com/cruru/question/exception/badrequest/ChoiceIllegalSaveException.java new file mode 100644 index 000000000..551454ddf --- /dev/null +++ b/backend/src/main/java/com/cruru/question/exception/badrequest/ChoiceIllegalSaveException.java @@ -0,0 +1,12 @@ +package com.cruru.question.exception.badrequest; + +import com.cruru.advice.badrequest.BadRequestException; + +public class ChoiceIllegalSaveException extends BadRequestException { + + private static final String MESSAGE = "선택지를 저장할 수 없는 질문입니다."; + + public ChoiceIllegalSaveException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/com/cruru/question/facade/QuestionFacade.java b/backend/src/main/java/com/cruru/question/facade/QuestionFacade.java new file mode 100644 index 000000000..8960c45ee --- /dev/null +++ b/backend/src/main/java/com/cruru/question/facade/QuestionFacade.java @@ -0,0 +1,40 @@ +package com.cruru.question.facade; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.service.ApplyFormService; +import com.cruru.question.controller.request.QuestionCreateRequest; +import com.cruru.question.controller.request.QuestionUpdateRequests; +import com.cruru.question.exception.QuestionUnmodifiableException; +import com.cruru.question.service.QuestionService; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class QuestionFacade { + + private final ApplyFormService applyFormService; + private final QuestionService questionService; + private final Clock clock; + + @Transactional + public void update(QuestionUpdateRequests request, long applyFormId) { + ApplyForm applyForm = applyFormService.findById(applyFormId); + validateRecruitmentStarted(applyForm); + questionService.deleteAllByApplyForm(applyForm); + List newQuestions = request.questions(); + + newQuestions.forEach(question -> questionService.create(question, applyForm)); + } + + private void validateRecruitmentStarted(ApplyForm applyForm) { + if (applyForm.hasStarted(LocalDate.now(clock))) { + throw new QuestionUnmodifiableException(); + } + } +} diff --git a/backend/src/main/java/com/cruru/question/service/AnswerService.java b/backend/src/main/java/com/cruru/question/service/AnswerService.java new file mode 100644 index 000000000..bb1e5780b --- /dev/null +++ b/backend/src/main/java/com/cruru/question/service/AnswerService.java @@ -0,0 +1,68 @@ +package com.cruru.question.service; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applyform.controller.request.AnswerCreateRequest; +import com.cruru.applyform.exception.badrequest.ReplyNotExistsException; +import com.cruru.question.controller.response.AnswerResponse; +import com.cruru.question.domain.Answer; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.AnswerRepository; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AnswerService { + + private static final String DELIMITER = ", "; + private static final String NOT_REPLIED = ""; + private final AnswerRepository answerRepository; + + @Transactional + public void saveAnswerReplies(AnswerCreateRequest answerCreateRequest, Question question, Applicant applicant) { + List replies = answerCreateRequest.replies(); + if (question.isRequired() && replies.isEmpty()) { + throw new ReplyNotExistsException(); + } + if (!question.isRequired() && replies.isEmpty()) { + answerRepository.save(new Answer(NOT_REPLIED, question, applicant)); + } + for (String reply : replies) { + Answer answer = new Answer(reply, question, applicant); + answerRepository.save(answer); + } + } + + public List findAllByApplicantWithQuestions(Applicant applicant) { + return answerRepository.findAllByApplicantWithQuestions(applicant); + } + + public List toAnswerResponses(List answers) { + Map answerResponses = answers.stream() + .collect(Collectors.toMap( + Answer::getQuestion, + this::createAnswerResponse, + this::mergeAnswers, + LinkedHashMap::new + )); + + return new ArrayList<>(answerResponses.values()); + } + + private AnswerResponse createAnswerResponse(Answer answer) { + Question question = answer.getQuestion(); + return new AnswerResponse(question.getSequence(), question.getContent(), answer.getContent()); + } + + private AnswerResponse mergeAnswers(AnswerResponse existing, AnswerResponse newResponse) { + String mergedContent = existing.answer() + DELIMITER + newResponse.answer(); + return new AnswerResponse(existing.sequence(), existing.question(), mergedContent); + } +} diff --git a/backend/src/main/java/com/cruru/question/service/ChoiceService.java b/backend/src/main/java/com/cruru/question/service/ChoiceService.java new file mode 100644 index 000000000..0f4dc4cb0 --- /dev/null +++ b/backend/src/main/java/com/cruru/question/service/ChoiceService.java @@ -0,0 +1,60 @@ +package com.cruru.question.service; + +import com.cruru.question.controller.request.ChoiceCreateRequest; +import com.cruru.question.controller.response.ChoiceResponse; +import com.cruru.question.domain.Choice; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.ChoiceRepository; +import com.cruru.question.exception.badrequest.ChoiceEmptyException; +import com.cruru.question.exception.badrequest.ChoiceIllegalSaveException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ChoiceService { + + private final ChoiceRepository choiceRepository; + + @Transactional + public List createAll(List requests, Question question) { + if (!question.hasChoice()) { + throw new ChoiceIllegalSaveException(); + } + if (requests.isEmpty()) { + throw new ChoiceEmptyException(); + } + return choiceRepository.saveAll(toChoices(requests, question)); + } + + private List toChoices(List requests, Question question) { + return requests.stream() + .map(request -> new Choice(request.choice(), request.orderIndex(), question)) + .toList(); + } + + public List findAllByQuestion(Question question) { + if (question.hasChoice()) { + return choiceRepository.findAllByQuestion(question); + } + return List.of(); + } + + public List toChoiceResponses(List choices) { + return choices.stream() + .map(this::toChoiceResponse) + .toList(); + } + + private ChoiceResponse toChoiceResponse(Choice choice) { + return new ChoiceResponse(choice.getId(), choice.getContent(), choice.getSequence()); + } + + @Transactional + public void deleteAllByQuestion(Question question) { + choiceRepository.deleteAllByQuestion(question); + } +} diff --git a/backend/src/main/java/com/cruru/question/service/QuestionService.java b/backend/src/main/java/com/cruru/question/service/QuestionService.java new file mode 100644 index 000000000..63f88f10e --- /dev/null +++ b/backend/src/main/java/com/cruru/question/service/QuestionService.java @@ -0,0 +1,92 @@ +package com.cruru.question.service; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.question.controller.request.QuestionCreateRequest; +import com.cruru.question.controller.response.ChoiceResponse; +import com.cruru.question.controller.response.QuestionResponse; +import com.cruru.question.domain.Choice; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.QuestionType; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.question.exception.QuestionNotFoundException; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class QuestionService { + + private final QuestionRepository questionRepository; + private final ChoiceService choiceService; + + @Transactional + public Question create(QuestionCreateRequest request, ApplyForm applyForm) { + Question savedQuestion = questionRepository.save(toQuestion(request, applyForm)); + + if (savedQuestion.hasChoice()) { + choiceService.createAll(request.choices(), savedQuestion); + } + + return savedQuestion; + } + + private Question toQuestion(QuestionCreateRequest request, ApplyForm applyForm) { + return new Question( + QuestionType.valueOf(request.type()), + request.question(), + request.orderIndex(), + request.required(), + applyForm + ); + } + + @Transactional + public void deleteAllByApplyForm(ApplyForm applyForm) { + List questions = questionRepository.findAllByApplyForm(applyForm); + for (Question question : questions) { + if (question.hasChoice()) { + choiceService.deleteAllByQuestion(question); + } + questionRepository.delete(question); + } + } + + public Question findById(Long id) { + return questionRepository.findById(id) + .orElseThrow(QuestionNotFoundException::new); + } + + public List findByApplyForm(ApplyForm applyForm) { + return questionRepository.findAllByApplyForm(applyForm); + } + + public List toQuestionResponses(List questions) { + return questions.stream() + .map(this::toQuestionResponse) + .toList(); + } + + private QuestionResponse toQuestionResponse(Question question) { + return new QuestionResponse( + question.getId(), + question.getQuestionType().name(), + question.getContent(), + question.getSequence(), + getChoiceResponses(question), + question.isRequired() + ); + } + + private List getChoiceResponses(Question question) { + List choiceResponses = new ArrayList<>(); + if (question.hasChoice()) { + List choices = choiceService.findAllByQuestion(question); + return choiceService.toChoiceResponses(choices); + } + return choiceResponses; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 000000000..7143eae14 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,318 @@ +spring: + profiles: + active: local + +--- +spring: + config: + activate: + on-profile: local + h2: + console: + enabled: true + datasource: + read: + jdbc-url: jdbc:h2:mem:database;MODE=MySQL; + write: + jdbc-url: jdbc:h2:mem:database;MODE=MySQL; + flyway: + enabled: false + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: create-drop + defer-datasource-initialization: false + mail: + host: smtp.gmail.com + port: 587 + username: ${EMAIL_USERNAME} # 테스트 시 직접 값 하드코딩 할 것 + password: ${EMAIL_PASSWORD} # 테스트 시 직접 값 하드코딩 할 것 + properties: + mail: + smtp: + auth: true + starttls: + enable: true + servlet: + multipart: + enabled: true + file-size-threshold: 2KB + max-file-size: 25MB + max-request-size: 50MB + +security: + jwt: + token: + secret-key: test + expire-length: 3600000 + algorithm: HS256 + +cookie: + access-token-key: token + http-only: false + secure: false + domain: localhost + path: / + same-site: none + max-age: 7200 #2시간 + +dataloader: + enable: true + +monitoring-profile: local + +--- +spring: + config: + activate: + on-profile: dev + datasource: + read: + jdbc-url: ${DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USER} + password: ${DB_PASSWORD} + write: + jdbc-url: ${DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USER} + password: ${DB_PASSWORD} + flyway: + enabled: true + baseline-on-migrate: true + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: ${DDL_AUTO} + defer-datasource-initialization: false + mail: + host: smtp.gmail.com + port: 587 + username: ${EMAIL_USERNAME} + password: ${EMAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + servlet: + multipart: + enabled: true + file-size-threshold: 2KB + max-file-size: 25MB + max-request-size: 50MB + +security: + jwt: + token: + secret-key: ${JWT_TOKEN_SECRET_KEY} + expire-length: ${JWT_TOKEN_EXPIRE_CYCLE} + algorithm: ${JWT_SIGN_ALGORITHM} + +cookie: + access-token-key: ${COOKIE_ACCESS_TOKEN_KEY} + http-only: ${COOKIE_HTTP_ONLY} + secure: ${COOKIE_SECURE} + domain: ${COOKIE_DOMAIN} + path: ${COOKIE_PATH} + same-site: ${COOKIE_SAME_SITE} + max-age: ${COOKIE_MAX_AGE} + +dataloader: + enable: true +server: + port: ${SERVER_PORT} + +management: + server: + port: ${MONITORING_PORT} + endpoints: + web: + base-path: ${MONITORING_BASE_PATH} + exposure: + include: prometheus + enabled-by-default: false + jmx: + exposure: + exclude: "*" + endpoint: + prometheus: + enabled: true + +monitoring-profile: develop + +--- +spring: + config: + activate: + on-profile: test + datasource: + read: + jdbc-url: ${DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USER} + password: ${DB_PASSWORD} + write: + jdbc-url: ${DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USER} + password: ${DB_PASSWORD} + flyway: + enabled: true + baseline-on-migrate: true + jpa: + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: ${DDL_AUTO} + defer-datasource-initialization: false + mail: + host: smtp.gmail.com + port: 587 + username: ${EMAIL_USERNAME} + password: ${EMAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + servlet: + multipart: + enabled: true + file-size-threshold: 2KB + max-file-size: 25MB + max-request-size: 50MB + +security: + jwt: + token: + secret-key: ${JWT_TOKEN_SECRET_KEY} + expire-length: ${JWT_TOKEN_EXPIRE_CYCLE} + algorithm: ${JWT_SIGN_ALGORITHM} + +cookie: + access-token-key: ${COOKIE_ACCESS_TOKEN_KEY} + http-only: ${COOKIE_HTTP_ONLY} + secure: ${COOKIE_SECURE} + domain: ${COOKIE_DOMAIN} + path: ${COOKIE_PATH} + same-site: ${COOKIE_SAME_SITE} + max-age: ${COOKIE_MAX_AGE} + +dataloader: + enable: false +server: + port: ${SERVER_PORT} + +management: + server: + port: ${MONITORING_PORT} + endpoints: + web: + base-path: ${MONITORING_BASE_PATH} + exposure: + include: prometheus + enabled-by-default: false + jmx: + exposure: + exclude: "*" + endpoint: + prometheus: + enabled: true + +monitoring-profile: test + +--- +spring: + config: + activate: + on-profile: prod + datasource: + read: + jdbc-url: ${READ_DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USER} + password: ${DB_PASSWORD} + write: + jdbc-url: ${WRITE_DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USER} + password: ${DB_PASSWORD} + flyway: + enabled: true + baseline-on-migrate: true + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: ${DDL_AUTO} + defer-datasource-initialization: false + mail: + host: smtp.gmail.com + port: 587 + username: ${EMAIL_USERNAME} + password: ${EMAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + servlet: + multipart: + enabled: true + file-size-threshold: 2KB + max-file-size: 25MB + max-request-size: 50MB + +security: + jwt: + token: + secret-key: ${JWT_TOKEN_SECRET_KEY} + expire-length: ${JWT_TOKEN_EXPIRE_CYCLE} + algorithm: ${JWT_SIGN_ALGORITHM} + +cookie: + access-token-key: ${COOKIE_ACCESS_TOKEN_KEY} + http-only: ${COOKIE_HTTP_ONLY} + secure: ${COOKIE_SECURE} + domain: ${COOKIE_DOMAIN} + path: ${COOKIE_PATH} + same-site: ${COOKIE_SAME_SITE} + max-age: ${COOKIE_MAX_AGE} + +dataloader: + enable: false +server: + port: ${SERVER_PORT} + +management: + server: + port: ${MONITORING_PORT} + endpoints: + web: + base-path: ${MONITORING_BASE_PATH} + exposure: + include: prometheus + enabled-by-default: false + jmx: + exposure: + exclude: "*" + endpoint: + prometheus: + enabled: true + +monitoring-profile: production diff --git a/backend/src/main/resources/banner.txt b/backend/src/main/resources/banner.txt new file mode 100644 index 000000000..a845909cb --- /dev/null +++ b/backend/src/main/resources/banner.txt @@ -0,0 +1,16 @@ + + +=========================================================================================== + + + ██████╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ ██╗ + ██╔════╝ ██╔══██╗ ██║ ██║ ██╔══██╗ ██║ ██║ ██║ + ██║ ██████╔╝ ██║ ██║ ██████╔╝ ██║ ██║ ██║ + ██║ ██╔══██╗ ██║ ██║ ██╔══██╗ ██║ ██║ ╚═╝ + ╚██████╗ ██║ ██║ ╚██████╔╝ ██║ ██║ ╚██████╔╝ ██╗ + ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ + + +=========================================================================================== + + diff --git a/backend/src/main/resources/db/migration/V1_1__init_constraints.sql b/backend/src/main/resources/db/migration/V1_1__init_constraints.sql new file mode 100644 index 000000000..d97a03e99 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1_1__init_constraints.sql @@ -0,0 +1,54 @@ +ALTER TABLE answer + ADD CONSTRAINT fk_answer_to_applicant + FOREIGN KEY (applicant_id) + REFERENCES applicant(applicant_id); + +ALTER TABLE answer + ADD CONSTRAINT fk_answer_to_question + FOREIGN KEY (question_id) + REFERENCES question(question_id); + +ALTER TABLE applicant + ADD CONSTRAINT fk_applicant_to_process + FOREIGN KEY (process_id) + REFERENCES process(process_id); + +ALTER TABLE apply_form + ADD CONSTRAINT fk_apply_form_to_dashboard + FOREIGN KEY (dashboard_id) + REFERENCES dashboard(dashboard_id); + +ALTER TABLE choice + ADD CONSTRAINT fk_choice_to_question + FOREIGN KEY (question_id) + REFERENCES question(question_id); + +ALTER TABLE club + ADD CONSTRAINT fk_club_to_member + FOREIGN KEY (member_id) + REFERENCES member(member_id); + +ALTER TABLE dashboard + ADD CONSTRAINT fk_dashboard_to_club + FOREIGN KEY (club_id) + REFERENCES club(club_id); + +ALTER TABLE evaluation + ADD CONSTRAINT fk_evaluation_to_applicant + FOREIGN KEY (applicant_id) + REFERENCES applicant(applicant_id); + +ALTER TABLE evaluation + ADD CONSTRAINT fk_evaluation_to_process + FOREIGN KEY (process_id) + REFERENCES process(process_id); + +ALTER TABLE process + ADD CONSTRAINT fk_process_to_dashboard + FOREIGN KEY (dashboard_id) + REFERENCES dashboard(dashboard_id); + +ALTER TABLE question + ADD CONSTRAINT fk_question_to_apply_form + FOREIGN KEY (apply_form_id) + REFERENCES apply_form(apply_form_id); diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 000000000..6fd2c5bb0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,125 @@ +CREATE TABLE answer +( + answer_id BIGINT NOT NULL AUTO_INCREMENT, + applicant_id BIGINT NOT NULL, + question_id BIGINT NOT NULL, + content TEXT, + PRIMARY KEY (answer_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE applicant +( + applicant_id BIGINT NOT NULL AUTO_INCREMENT, + created_date DATETIME(6), + process_id BIGINT NOT NULL, + updated_date DATETIME(6), + email VARCHAR(255), + name VARCHAR(255), + phone VARCHAR(255), + is_rejected BOOLEAN, + PRIMARY KEY (applicant_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE apply_form +( + apply_form_id BIGINT NOT NULL AUTO_INCREMENT, + created_date DATETIME(6), + dashboard_id BIGINT NOT NULL, + end_date DATETIME(6), + start_date DATETIME(6), + updated_date DATETIME(6), + description TEXT, + title VARCHAR(1023), + url VARCHAR(1023), + PRIMARY KEY (apply_form_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE choice +( + choice_id BIGINT NOT NULL AUTO_INCREMENT, + sequence INTEGER, + question_id BIGINT NOT NULL, + content VARCHAR(1023), + PRIMARY KEY (choice_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE club +( + club_id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + name VARCHAR(1023), + PRIMARY KEY (club_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE dashboard +( + dashboard_id BIGINT NOT NULL AUTO_INCREMENT, + club_id BIGINT NOT NULL, + PRIMARY KEY (dashboard_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE evaluation +( + evaluation_id BIGINT NOT NULL AUTO_INCREMENT, + score INTEGER, + applicant_id BIGINT NOT NULL, + created_date DATETIME(6), + process_id BIGINT NOT NULL, + updated_date DATETIME(6), + content TEXT, + PRIMARY KEY (evaluation_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE member +( + member_id BIGINT NOT NULL AUTO_INCREMENT, + created_date DATETIME(6), + updated_date DATETIME(6), + email VARCHAR(255) UNIQUE, + password VARCHAR(2047), + phone VARCHAR(511), + role VARCHAR(255), + PRIMARY KEY (member_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE process +( + process_id BIGINT NOT NULL AUTO_INCREMENT, + sequence INTEGER, + dashboard_id BIGINT NOT NULL, + description TEXT, + name VARCHAR(255), + type VARCHAR(255), + PRIMARY KEY (process_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE question +( + question_id BIGINT NOT NULL AUTO_INCREMENT, + required BOOLEAN, + sequence INTEGER, + apply_form_id BIGINT NOT NULL, + content TEXT, + question_type VARCHAR(255), + PRIMARY KEY (question_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V2_1_1__init_email_constraints.sql b/backend/src/main/resources/db/migration/V2_1_1__init_email_constraints.sql new file mode 100644 index 000000000..02ee594bf --- /dev/null +++ b/backend/src/main/resources/db/migration/V2_1_1__init_email_constraints.sql @@ -0,0 +1,9 @@ +ALTER TABLE email + ADD CONSTRAINT fk_email_to_club + FOREIGN KEY (club_id) + REFERENCES club (club_id); + +ALTER TABLE email + ADD CONSTRAINT fk_email_to_applicant + FOREIGN KEY (applicant_id) + REFERENCES applicant (applicant_id); diff --git a/backend/src/main/resources/db/migration/V2_1_2__create_evaluation_index.sql b/backend/src/main/resources/db/migration/V2_1_2__create_evaluation_index.sql new file mode 100644 index 000000000..edd946f43 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2_1_2__create_evaluation_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_applicant_id_and_process_id ON evaluation (applicant_id, process_id); +CREATE INDEX idx_applicant_id_and_evaluation_id_and_score ON evaluation (applicant_id, evaluation_id, score); diff --git a/backend/src/main/resources/db/migration/V2_1__init_email.sql b/backend/src/main/resources/db/migration/V2_1__init_email.sql new file mode 100644 index 000000000..a9649150f --- /dev/null +++ b/backend/src/main/resources/db/migration/V2_1__init_email.sql @@ -0,0 +1,14 @@ +CREATE TABLE email +( + email_id BIGINT NOT NULL AUTO_INCREMENT, + applicant_id BIGINT, + club_id BIGINT, + created_date DATETIME(6), + updated_date DATETIME(6), + subject VARCHAR(1023), + content TEXT, + is_succeed BOOLEAN, + PRIMARY KEY (email_id) +) ENGINE=InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V2__delete_applyform_url.sql b/backend/src/main/resources/db/migration/V2__delete_applyform_url.sql new file mode 100644 index 000000000..e3493388f --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__delete_applyform_url.sql @@ -0,0 +1 @@ +ALTER TABLE apply_form DROP COLUMN url; \ No newline at end of file diff --git a/backend/src/main/resources/logback.xml b/backend/src/main/resources/logback.xml new file mode 100644 index 000000000..afb563c46 --- /dev/null +++ b/backend/src/main/resources/logback.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + + + + + + + INFO + ACCEPT + DENY + + + ${LOG_PATTERN} + + + ./log/info/%d{yyyy-MM-dd}.%i.log + 100MB + 30 + + + + + + + WARN + ACCEPT + DENY + + + ${LOG_PATTERN} + + + ./log/warn/%d{yyyy-MM-dd}.%i.log + 100MB + 30 + + + + + + + ERROR + ACCEPT + DENY + + + ${LOG_PATTERN} + + + ./log/error/%d{yyyy-MM-dd}.%i.log + 100MB + 30 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/test/java/com/cruru/applicant/controller/ApplicantControllerTest.java b/backend/src/test/java/com/cruru/applicant/controller/ApplicantControllerTest.java new file mode 100644 index 000000000..1e2039385 --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/controller/ApplicantControllerTest.java @@ -0,0 +1,369 @@ +package com.cruru.applicant.controller; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +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.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.applicant.controller.request.ApplicantMoveRequest; +import com.cruru.applicant.controller.request.ApplicantUpdateRequest; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.AnswerRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.util.ControllerTest; +import com.cruru.util.fixture.AnswerFixture; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.ProcessFixture; +import com.cruru.util.fixture.QuestionFixture; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.restdocs.payload.FieldDescriptor; + +@DisplayName("지원자 컨트롤러 테스트") +class ApplicantControllerTest extends ControllerTest { + + private static final FieldDescriptor[] ANSWER_RESPONSE_FIELD_DESCRIPTORS = { + fieldWithPath("orderIndex").description("순서"), + fieldWithPath("question").description("질문"), + fieldWithPath("answer").description("질문에 대한 응답") + }; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private AnswerRepository answerRepository; + + @DisplayName("지원자들의 프로세스를 일괄적으로 옮기는 데 성공하면 200을 응답한다.") + @Test + void updateProcess() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend(defaultClub)); + Process now = processRepository.save(ProcessFixture.applyType(dashboard)); + Process next = processRepository.save(ProcessFixture.approveType(dashboard)); + Applicant applicant = ApplicantFixture.pendingDobby(now); + applicantRepository.save(applicant); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(new ApplicantMoveRequest(List.of(applicant.getId()))) + .filter(document( + "applicant/move-process/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("processId").description("지원자들이 옮겨질 프로세스의 id")), + requestFields(fieldWithPath("applicantIds").description("프로세스를 옮길 지원자들의 id")) + )) + .when().put("/v1/applicants/move-process/{processId}", next.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("존재하지 않는 프로세스로 지원자를 옮기려 시도하면 404를 응답한다.") + @Test + void updateApplicantProcess_processNotFound() { + // given + Process now = processRepository.save(ProcessFixture.applyType()); + Applicant applicant = ApplicantFixture.pendingDobby(now); + applicantRepository.save(applicant); + Long invalidProcessId = -1L; + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(new ApplicantMoveRequest(List.of(applicant.getId()))) + .filter(document( + "applicant/move-process-fail/process-not-found/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("processId").description("존재하지 않는 프로세스의 id")), + requestFields(fieldWithPath("applicantIds").description("프로세스를 옮길 지원자들의 id")) + )) + .when().put("/v1/applicants/move-process/{processId}", invalidProcessId) + .then().log().all().statusCode(404); + } + + @DisplayName("지원자의 기본 정보를 읽어오는 데 성공하면 200을 응답한다.") + @Test + void read() { + // given + Process process = processRepository.save(ProcessFixture.applyType()); + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "applicant/read-profile", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("지원자의 id")), + responseFields( + fieldWithPath("applicant.id").description("지원자의 id"), + fieldWithPath("applicant.name").description("지원자의 이름"), + fieldWithPath("applicant.email").description("지원자의 이메일"), + fieldWithPath("applicant.phone").description("지원자의 전화번호"), + fieldWithPath("applicant.isRejected").description("지원자의 불합격 여부"), + fieldWithPath("applicant.createdAt").description("지원자의 생성날짜"), + fieldWithPath("process.id").description("프로세스의 id"), + fieldWithPath("process.name").description("프로세스 이름") + ) + )) + .when().get("/v1/applicants/{applicantId}", applicant.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("존재하지 않는 지원자 정보 조회를 시도하면 404를 응답한다.") + @Test + void read_applicantNotFound() { + // given + Long invalidApplicantId = -1L; + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .filter(document( + "applicant/read-profile-fail/applicant-not-found/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("존재하지 않는 지원자의 id")) + )) + .when().get("/v1/applicants/{applicantId}", invalidApplicantId) + .then().log().all().statusCode(404); + } + + @DisplayName("지원자의 상세 정보를 읽어오는 데 성공하면 200을 응답한다.") + @Test + void readDetail() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + Process process = processRepository.save(ProcessFixture.applyType(dashboard)); + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + Question question = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + answerRepository.save(AnswerFixture.first(question, applicant)); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "applicant/read-detail-profile", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("지원자의 id")), + responseFields( + fieldWithPath("details").description("답변들") + ).andWithPrefix("details[].", ANSWER_RESPONSE_FIELD_DESCRIPTORS) + )) + .when().get("/v1/applicants/{applicantId}/detail", applicant.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("존재하지 않는 지원자 정보 조회를 시도하면 404를 응답한다.") + @Test + void readDetail_applicantNotFound() { + // given + long invalidApplicantId = -1; + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "applicant/read-detail-profile-fail/applicant-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("지원자의 id")) + )) + .when().get("/v1/applicants/{applicantId}/detail", invalidApplicantId) + .then().log().all().statusCode(404); + } + + @DisplayName("지원자를 불합격시키는 데 성공하면 200을 응답한다.") + @Test + void reject() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "applicant/reject", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("지원자의 id")) + )) + .when().patch("/v1/applicants/{applicantId}/reject", applicant.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("존재하지 않는 지원자 불합격 시, 404를 응답한다.") + @Test + void reject_applicantNotFound() { + // given + long invalidApplicantId = 1; + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .filter(document( + "applicant/reject-fail/applicant-not-found/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("지원자의 id")) + )) + .when().patch("/v1/applicants/{applicantId}/reject", invalidApplicantId) + .then().log().all().statusCode(404); + } + + @DisplayName("이미 불합격한 지원자일 경우 400를 응답한다.") + @Test + void reject_alreadyReject() { + // given + Applicant applicant = ApplicantFixture.pendingDobby(); + applicant.reject(); + Applicant savedApplicant = applicantRepository.save(applicant); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .filter(document( + "applicant/reject-fail/already-rejected/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("존재하지 않는 지원자의 id")) + )) + .when().patch("/v1/applicants/{applicantId}/reject", savedApplicant.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("지원자를 불합격 해제시키는 데 성공하면 200을 응답한다.") + @Test + void unreject() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.rejectedRush()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "applicant/unreject", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("지원자의 id")) + )) + .when().patch("/v1/applicants/{applicantId}/unreject", applicant.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("존재하지 않는 지원자 불합격 해제 시, 404를 응답한다.") + @Test + void unreject_applicantNotFound() { + // given + long invalidApplicantId = -1; + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .filter(document( + "applicant/unreject-fail/applicant-not-found/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("존재하지 않는 지원자의 id")) + )) + .when().patch("/v1/applicants/{applicantId}/unreject", invalidApplicantId) + .then().log().all().statusCode(404); + } + + @DisplayName("불합격하지 않은 지원자 불합격 해제 시, 400를 응답한다.") + @Test + void unreject_notRejected() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .filter(document( + "applicant/unreject-fail/applicant-not-rejected/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("불합격하지 않는 지원자의 id")) + )) + .when().patch("/v1/applicants/{applicantId}/unreject", applicant.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("지원자 정보 변경에 성공하면 200을 응답한다.") + @Test + void updateInformation() { + // given + String toChangeName = "도비"; + String toChangeEmail = "dev.DOBBY@gmail.com"; + String toChangePhone = "01011111111"; + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby()); + ApplicantUpdateRequest request = new ApplicantUpdateRequest(toChangeName, toChangeEmail, toChangePhone); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document( + "applicant/change-info", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("지원자의 id")) + )) + .when().patch("/v1/applicants/{applicantId}", applicant.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("존재하지 않는 지원자의 정보 변경 시, 404를 응답한다.") + @Test + void updateInformation_applicantNotFound() { + // given + long invalidApplicantId = -1; + String toChangeName = "도비"; + String toChangeEmail = "dev.DOBBY@gmail.com"; + String toChangePhone = "01011111111"; + ApplicantUpdateRequest request = new ApplicantUpdateRequest(toChangeName, toChangeEmail, toChangePhone); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document( + "applicant/change-info-fail/applicant-not-found/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applicantId").description("존재하지 않는 지원자의 id")) + )) + .when().patch("/v1/applicants/{applicantId}", invalidApplicantId) + .then().log().all().statusCode(404); + } +} diff --git a/backend/src/test/java/com/cruru/applicant/controller/EvaluationControllerTest.java b/backend/src/test/java/com/cruru/applicant/controller/EvaluationControllerTest.java new file mode 100644 index 000000000..70b3e95b5 --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/controller/EvaluationControllerTest.java @@ -0,0 +1,362 @@ +package com.cruru.applicant.controller; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +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.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.applicant.controller.request.EvaluationCreateRequest; +import com.cruru.applicant.controller.request.EvaluationUpdateRequest; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applicant.domain.repository.EvaluationRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.util.ControllerTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.EvaluationFixture; +import com.cruru.util.fixture.ProcessFixture; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.restdocs.payload.FieldDescriptor; + +@DisplayName("평가 컨트롤러 테스트") +class EvaluationControllerTest extends ControllerTest { + + private static final FieldDescriptor[] EVALUATION_FIELD_DESCRIPTORS = { + fieldWithPath("evaluationId").description("평가의 id"), + fieldWithPath("score").description("평가 점수"), + fieldWithPath("content").description("평가 내용"), + fieldWithPath("createdDate").description("평가 생성 날짜") + }; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private EvaluationRepository evaluationRepository; + + private Process process; + + private Applicant applicant; + + @BeforeEach + void setUp() { + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend(defaultClub)); + process = processRepository.save(ProcessFixture.applyType(dashboard)); + applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + } + + @DisplayName("평가 생성 성공 시, 201을 응답한다.") + @Test + void create() { + // given + int score = 4; + String content = "서류가 인상적입니다."; + String url = String.format("/v1/evaluations?processId=%d&applicantId=%d", process.getId(), applicant.getId()); + EvaluationCreateRequest request = new EvaluationCreateRequest(score, content); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document( + "evaluation/create", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters( + parameterWithName("processId").description("프로세스의 id"), + parameterWithName("applicantId").description("지원자의 id") + ), + requestFields( + fieldWithPath("score").description("평가 점수"), + fieldWithPath("content").description("평가 주관식 내용") + ) + )) + .when().post(url) + .then().log().all().statusCode(201); + } + + @DisplayName("지원자가 존재하지 않을 경우, 404를 응답한다.") + @Test + void create_applicantNotFound() { + // given + int score = 4; + String content = "서류가 인상적입니다."; + long invalidApplicantId = -1; + String url = String.format( + "/v1/evaluations?processId=%d&applicantId=%d", + process.getId(), + invalidApplicantId + ); + EvaluationCreateRequest request = new EvaluationCreateRequest(score, content); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document( + "evaluation/create-fail/applicant-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters( + parameterWithName("processId").description("프로세스의 id"), + parameterWithName("applicantId").description("존재하지 않는 지원자의 id") + ), + requestFields( + fieldWithPath("score").description("평가 점수"), + fieldWithPath("content").description("평가 주관식 내용") + ) + )) + .when().post(url) + .then().log().all().statusCode(404); + } + + @DisplayName("프로세스가 존재하지 않을 경우, 404를 응답한다.") + @Test + void create_processNotFound() { + // given + int score = 4; + String content = "서류가 인상적입니다."; + Long invalidProcessId = -1L; + String url = String.format( + "/v1/evaluations?processId=%d&applicantId=%d", + invalidProcessId, + applicant.getId() + ); + EvaluationCreateRequest request = new EvaluationCreateRequest(score, content); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document( + "evaluation/create-fail/process-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters( + parameterWithName("processId").description("존재하지 않는 프로세스의 id"), + parameterWithName("applicantId").description("지원자의 id") + ), + requestFields( + fieldWithPath("score").description("평가 점수"), + fieldWithPath("content").description("평가 주관식 내용") + ) + )) + .when().post(url) + .then().log().all().statusCode(404); + } + + @DisplayName("유효하지 않는 점수일 경우, 400을 응답한다.") + @Test + void create_invalidScore() { + // given + int invalidScore = -4; + String content = "서류가 인상적입니다."; + String url = String.format( + "/v1/evaluations?processId=%d&applicantId=%d", + process.getId(), + applicant.getId() + ); + EvaluationCreateRequest request = new EvaluationCreateRequest(invalidScore, content); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document( + "evaluation/create-fail/invalid-score", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters( + parameterWithName("processId").description("프로세스의 id"), + parameterWithName("applicantId").description("지원자의 id") + ), + requestFields( + fieldWithPath("score").description("적절하지 않은 평가 점수"), + fieldWithPath("content").description("평가 주관식 내용") + ) + )) + .when().post(url) + .then().log().all().statusCode(400); + } + + @DisplayName("평가 조회에 성공할 경우, 200을 응답한다.") + @Test + void read() { + // given + evaluationRepository.save(EvaluationFixture.fivePoints(process, applicant)); + String url = String.format("/v1/evaluations?processId=%d&applicantId=%d", process.getId(), applicant.getId()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .filter(document( + "evaluation/read", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters( + parameterWithName("processId").description("프로세스의 id"), + parameterWithName("applicantId").description("지원자의 id") + ), + responseFields( + fieldWithPath("evaluations").description("평가 목록") + ).andWithPrefix("evaluations[].", EVALUATION_FIELD_DESCRIPTORS) + )) + .when().get(url) + .then().log().all().statusCode(200); + } + + @DisplayName("지원자가 존재하지 않을 경우, 404를 응답한다.") + @Test + void read_applicantNotFound() { + // given + long invalidApplicantId = -1; + String url = String.format( + "/v1/evaluations?processId=%d&applicantId=%d", + process.getId(), + invalidApplicantId + ); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .filter(document( + "evaluation/read-fail/applicant-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters( + parameterWithName("processId").description("프로세스의 id"), + parameterWithName("applicantId").description("지원자의 id") + ) + )) + .when().get(url) + .then().log().all().statusCode(404); + } + + @DisplayName("프로세스가 존재하지 않을 경우, 404를 응답한다.") + @Test + void read_processNotFound() { + // given + long invalidProcessId = -1; + String url = String.format( + "/v1/evaluations?processId=%d&applicantId=%d", + invalidProcessId, + applicant.getId() + ); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .filter(document( + "evaluation/read-fail/process-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters( + parameterWithName("processId").description("프로세스의 id"), + parameterWithName("applicantId").description("지원자의 id") + ) + )) + .when().get(url) + .then().log().all().statusCode(404); + } + + @DisplayName("평가 수정에 성공할 경우, 200을 응답한다.") + @Test + void update() { + // given + Evaluation evaluation = evaluationRepository.save(EvaluationFixture.fivePoints(process, applicant)); + int score = 2; + String content = "맞춤법이 틀렸습니다."; + EvaluationUpdateRequest request = new EvaluationUpdateRequest(score, content); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document( + "evaluation/update", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("evaluationId").description("평가의 id")), + requestFields( + fieldWithPath("score").description("평가 점수"), + fieldWithPath("content").description("평가 주관식 내용") + ) + )) + .when().patch("/v1/evaluations/{evaluationId}", evaluation.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("평가 수정시 평가가 존재하지 않을 경우, 404를 응답한다.") + @Test + void update_evaluationNotFound() { + // given + int score = 2; + String content = "맞춤법이 틀렸습니다."; + EvaluationUpdateRequest request = new EvaluationUpdateRequest(score, content); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document( + "evaluation/update-fail/evaluation-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("evaluationId").description("존재하지 않는 평가의 id")), + requestFields( + fieldWithPath("score").description("평가 점수"), + fieldWithPath("content").description("평가 주관식 내용") + ) + )) + .when().patch("/v1/evaluations/{evaluationId}", -1) + .then().log().all().statusCode(404); + } + + @DisplayName("평가 수정시 평가가 유효하지 않은 점수일 경우, 400를 응답한다.") + @Test + void update_invalidScore() { + // given + Evaluation evaluation = evaluationRepository.save(EvaluationFixture.fivePoints()); + int score = -1; + String content = "맞춤법이 틀렸습니다."; + EvaluationUpdateRequest request = new EvaluationUpdateRequest(score, content); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document( + "evaluation/update-fail/invalid-score", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("evaluationId").description("평가의 id")), + requestFields( + fieldWithPath("score").description("적절하지 않은 평가 점수"), + fieldWithPath("content").description("평가 주관식 내용") + ) + )) + .when().patch("/v1/evaluations/{evaluationId}", evaluation.getId()) + .then().log().all().statusCode(400); + } +} diff --git a/backend/src/test/java/com/cruru/applicant/domain/ApplicantTest.java b/backend/src/test/java/com/cruru/applicant/domain/ApplicantTest.java new file mode 100644 index 000000000..6c15ac6bf --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/domain/ApplicantTest.java @@ -0,0 +1,140 @@ +package com.cruru.applicant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.exception.badrequest.ApplicantIllegalPhoneNumberException; +import com.cruru.applicant.exception.badrequest.ApplicantNameBlankException; +import com.cruru.applicant.exception.badrequest.ApplicantNameCharacterException; +import com.cruru.applicant.exception.badrequest.ApplicantNameLengthException; +import com.cruru.util.fixture.ApplicantFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("지원서 도메인 테스트") +class ApplicantTest { + + @DisplayName("지원자 이름은 한글, 영어, 공백, '-'를 허용한다.") + @ValueSource(strings = {"도비", "dobby", "김 도비", "kim-dobby"}) + @ParameterizedTest + void validApplicantName(String name) { + // given&when&then + assertThatCode(() -> new Applicant(name, "mail@mail.com", "01012341234", null)).doesNotThrowAnyException(); + } + + @DisplayName("지원자 이름이 비어있으면 예외가 발생한다.") + @ValueSource(strings = {"", " "}) + @ParameterizedTest + void ApplicantNameBlank(String name) { + // given + Applicant applicant = ApplicantFixture.pendingDobby(); + + // when&then + assertAll( + () -> assertThatThrownBy(() -> new Applicant(name, "mail@mail.com", "01012341234", null)) + .isInstanceOf(ApplicantNameBlankException.class), + () -> assertThatThrownBy(() -> applicant.updateInfo(name, "new@mail.com", "01012341234")) + .isInstanceOf(ApplicantNameBlankException.class) + ); + } + + @DisplayName("지원자 이름이 32자 초과시 예외가 발생한다.") + @Test + void invalidApplicantNameLength() { + // given + String name = "ThisStringLengthIsThirtyThreeAbcd"; + Applicant applicant = ApplicantFixture.pendingDobby(); + + // when&then + assertAll( + () -> assertThatThrownBy(() -> new Applicant(name, "mail@mail.com", "01012341234", null)) + .isInstanceOf(ApplicantNameLengthException.class), + () -> assertThatThrownBy(() -> applicant.updateInfo(name, "new@mail.com", "01012341234")) + .isInstanceOf(ApplicantNameLengthException.class) + ); + } + + @DisplayName("지원자 이름에 허용되지 않은 글자가 들어가면 예외가 발생한다.") + @ValueSource(strings = {"invalidCharacter!", "invalidCharacter~"}) + @ParameterizedTest + void invalidApplicantNameCharacter(String name) { + // given + Applicant applicant = ApplicantFixture.pendingDobby(); + + // when&then + assertAll( + () -> assertThatThrownBy(() -> new Applicant(name, "mail@mail.com", "01012341234", null)) + .isInstanceOf(ApplicantNameCharacterException.class), + () -> assertThatThrownBy(() -> applicant.updateInfo(name, "new@mail.com", "01012341234")) + .isInstanceOf(ApplicantNameCharacterException.class) + ); + } + + @DisplayName("지원자 전화번호의 형식이 일치하지 않으면 예외가 발생한다.") + @ValueSource(strings = {"010111122222", "40391385", "phone?"}) + @ParameterizedTest + void invalidApplicantPhoneNumber(String phone) { + // given + Applicant applicant = ApplicantFixture.pendingDobby(); + + // when&then + assertAll( + () -> assertThatThrownBy(() -> new Applicant("dobby", "mail@mail.com", phone, null)) + .isInstanceOf(ApplicantIllegalPhoneNumberException.class), + () -> assertThatThrownBy(() -> applicant.updateInfo("dobby", "new@mail.com", phone)) + .isInstanceOf(ApplicantIllegalPhoneNumberException.class) + ); + } + + @DisplayName("지원자 이름, 이메일, 전화번호 변경에 성공한다.") + @Test + void updateInfo() { + // given + String toChangeName = "초코칩"; + String toChangeEmail = "dev.chocochip@gmail.com"; + String toChangePhone = "01000000000"; + + Applicant applicant = ApplicantFixture.pendingDobby(); + + // when + applicant.updateInfo(toChangeName, toChangeEmail, toChangePhone); + + // then + assertAll( + () -> assertThat(applicant.getName()).isEqualTo(toChangeName), + () -> assertThat(applicant.getEmail()).isEqualTo(toChangeEmail), + () -> assertThat(applicant.getPhone()).isEqualTo(toChangePhone), + () -> assertThat(applicant.getProcess()).isNull() + ); + } + + @DisplayName("지원자를 불합격시킨다.") + @Test + void reject() { + // given + Applicant applicant = ApplicantFixture.pendingDobby(); + + // when + applicant.reject(); + + // then + assertThat(applicant.isRejected()).isTrue(); + } + + @DisplayName("지원자의 불합격을 취소한다.") + @Test + void unreject() { + // given + Applicant applicant = ApplicantFixture.rejectedRush(); + + // when + applicant.unreject(); + + // then + assertThat(applicant.isNotRejected()).isTrue(); + } +} diff --git a/backend/src/test/java/com/cruru/applicant/domain/EvaluationTest.java b/backend/src/test/java/com/cruru/applicant/domain/EvaluationTest.java new file mode 100644 index 000000000..18f6f3f25 --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/domain/EvaluationTest.java @@ -0,0 +1,24 @@ +package com.cruru.applicant.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.cruru.applicant.exception.badrequest.EvaluationScoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("평가 도메인 테스트") +class EvaluationTest { + + @DisplayName("잘못된 평가 점수로 생성 시 예외가 발생한다.") + @ValueSource(ints = {-1, 0, 6}) + @ParameterizedTest + void invalidEvaluationScore(int invalidScore) { + // given + String content = "포트폴리오가 인상적입니다."; + + // when&then + assertThatThrownBy(() -> new Evaluation(invalidScore, content, null, null)) + .isInstanceOf(EvaluationScoreException.class); + } +} diff --git a/backend/src/test/java/com/cruru/applicant/domain/repository/ApplicantRepositoryTest.java b/backend/src/test/java/com/cruru/applicant/domain/repository/ApplicantRepositoryTest.java new file mode 100644 index 000000000..2727ab728 --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/domain/repository/ApplicantRepositoryTest.java @@ -0,0 +1,225 @@ +package com.cruru.applicant.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.applicant.domain.dto.ApplicantCard; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.util.RepositoryTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.ProcessFixture; +import java.util.List; +import com.cruru.util.fixture.EvaluationFixture; +import com.cruru.util.fixture.ProcessFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("지원자 레포지토리 테스트") +class ApplicantRepositoryTest extends RepositoryTest { + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private EvaluationRepository evaluationRepository; + + @BeforeEach + void setUp() { + applicantRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어 있는 ID를 가진 프로세스를 저장하면, 해당 ID의 프로세스는 후에 작성된 정보로 업데이트한다.") + @Test + void sameIdUpdate() { + //given + Applicant applicant = ApplicantFixture.pendingDobby(); + Applicant saved = applicantRepository.save(applicant); + + //when + Applicant updatedApplicant = new Applicant(saved.getId(), "다른이름", "다른이메일", "다른번호", null, false); + applicantRepository.save(updatedApplicant); + + //then + Applicant foundApplicant = applicantRepository.findById(saved.getId()).get(); + assertThat(foundApplicant.getName()).isEqualTo("다른이름"); + assertThat(foundApplicant.getEmail()).isEqualTo("다른이메일"); + assertThat(foundApplicant.getPhone()).isEqualTo("다른번호"); + } + + @DisplayName("ID가 없는 프로세스를 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Applicant applicant1 = ApplicantFixture.pendingDobby(); + Applicant applicant2 = ApplicantFixture.pendingRush(); + + //when + Applicant savedApplicant1 = applicantRepository.save(applicant1); + Applicant savedApplicant2 = applicantRepository.save(applicant2); + + //then + assertThat(savedApplicant1.getId() + 1).isEqualTo(savedApplicant2.getId()); + } + + @DisplayName("특정 Process들에 대한 ApplicantCard 목록을 반환한다.") + @Test + void findApplicantCardsByProcesses() { + // given + Process process = processRepository.save(ProcessFixture.applyType()); + + Applicant applicant1 = ApplicantFixture.pendingDobby(process); + Applicant applicant2 = ApplicantFixture.pendingRush(process); + applicantRepository.saveAll(List.of(applicant1, applicant2)); + + List evaluations = List.of( + EvaluationFixture.fivePoints(process, applicant1), + EvaluationFixture.fivePoints(process, applicant1), + EvaluationFixture.fivePoints(process, applicant2) + ); + evaluationRepository.saveAll(evaluations); + + // when + List applicantCards = applicantRepository.findApplicantCardsByProcesses(List.of(process)); + + // then + assertThat(applicantCards).hasSize(2); + + ApplicantCard applicantCard1 = applicantCards.get(0); + assertAll( + () -> assertThat(applicantCard1.id()).isEqualTo(applicant1.getId()), + () -> assertThat(applicantCard1.name()).isEqualTo(applicant1.getName()), + () -> assertThat(applicantCard1.evaluationCount()).isEqualTo(2), + () -> assertThat(applicantCard1.averageScore()).isEqualTo(5.0) + ); + + ApplicantCard applicantCard2 = applicantCards.get(1); + assertAll( + () -> assertThat(applicantCard2.id()).isEqualTo(applicant2.getId()), + () -> assertThat(applicantCard2.name()).isEqualTo(applicant2.getName()), + () -> assertThat(applicantCard2.evaluationCount()).isEqualTo(1), + () -> assertThat(applicantCard2.averageScore()).isEqualTo(5.0) + ); + } + + @DisplayName("평가가 없을 경우 ApplicantCard 목록에서 평균 점수는 0점이고 카운트는 0이다.") + @Test + void findApplicantCardsByProcesses_noEvaluations() { + // given + Process process = processRepository.save(ProcessFixture.applyType()); + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + + // when + List applicantCards = applicantRepository.findApplicantCardsByProcesses(List.of(process)); + + // then + assertThat(applicantCards).hasSize(1); + ApplicantCard applicantCard = applicantCards.get(0); + + assertAll( + () -> assertThat(applicantCard.id()).isEqualTo(applicant.getId()), + () -> assertThat(applicantCard.name()).isEqualTo(applicant.getName()), + () -> assertThat(applicantCard.evaluationCount()).isZero(), + () -> assertThat(applicantCard.averageScore()).isZero() + ); + } + + @DisplayName("특정 Process에 대한 ApplicantCard 목록을 반환한다.") + @Test + void findApplicantCardsByProcess() { + // given + Process process = processRepository.save(ProcessFixture.applyType()); + + Applicant applicant1 = ApplicantFixture.pendingDobby(process); + Applicant applicant2 = ApplicantFixture.pendingRush(process); + applicantRepository.saveAll(List.of(applicant1, applicant2)); + + List evaluations = List.of( + EvaluationFixture.fivePoints(process, applicant1), + EvaluationFixture.fivePoints(process, applicant1), + EvaluationFixture.fivePoints(process, applicant2) + ); + evaluationRepository.saveAll(evaluations); + + // when + List applicantCards = applicantRepository.findApplicantCardsByProcess(process); + + // then + assertThat(applicantCards).hasSize(2); + + ApplicantCard applicantCard1 = applicantCards.get(0); + assertAll( + () -> assertThat(applicantCard1.id()).isEqualTo(applicant1.getId()), + () -> assertThat(applicantCard1.name()).isEqualTo(applicant1.getName()), + () -> assertThat(applicantCard1.evaluationCount()).isEqualTo(2), + () -> assertThat(applicantCard1.averageScore()).isEqualTo(5.0) + ); + + ApplicantCard applicantCard2 = applicantCards.get(1); + assertAll( + () -> assertThat(applicantCard2.id()).isEqualTo(applicant2.getId()), + () -> assertThat(applicantCard2.name()).isEqualTo(applicant2.getName()), + () -> assertThat(applicantCard2.evaluationCount()).isEqualTo(1), + () -> assertThat(applicantCard2.averageScore()).isEqualTo(5.0) + ); + } + + @DisplayName("평가가 없을 경우 ApplicantCard 목록에서 평균 점수는 0점이고 카운트는 0이다.") + @Test + void findApplicantCardsByProcess_noEvaluations() { + // given + Process process = processRepository.save(ProcessFixture.applyType()); + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + + // when + List applicantCards = applicantRepository.findApplicantCardsByProcess(process); + + // then + assertThat(applicantCards).hasSize(1); + ApplicantCard applicantCard = applicantCards.get(0); + + assertAll( + () -> assertThat(applicantCard.id()).isEqualTo(applicant.getId()), + () -> assertThat(applicantCard.name()).isEqualTo(applicant.getName()), + () -> assertThat(applicantCard.evaluationCount()).isZero(), + () -> assertThat(applicantCard.averageScore()).isZero() + ); + } + + @DisplayName("특정 대시보드에 해당하는 지원자 목록을 반환한다.") + @Test + void findAllByDashboard() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + + Process process1 = processRepository.save(ProcessFixture.applyType(dashboard)); + Process process2 = processRepository.save(ProcessFixture.approveType(dashboard)); + + Applicant applicant1 = applicantRepository.save(ApplicantFixture.pendingDobby(process1)); + Applicant applicant2 = applicantRepository.save(ApplicantFixture.pendingDobby(process1)); + Applicant applicant3 = applicantRepository.save(ApplicantFixture.pendingDobby(process2)); + + // when + List applicants = applicantRepository.findAllByDashboard(dashboard); + + // then + assertThat(applicants).hasSize(3); + assertThat(applicants).containsExactlyInAnyOrder(applicant1, applicant2, applicant3); + } +} diff --git a/backend/src/test/java/com/cruru/applicant/domain/repository/EvaluationRepositoryTest.java b/backend/src/test/java/com/cruru/applicant/domain/repository/EvaluationRepositoryTest.java new file mode 100644 index 000000000..1789c8983 --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/domain/repository/EvaluationRepositoryTest.java @@ -0,0 +1,55 @@ +package com.cruru.applicant.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.cruru.applicant.domain.Evaluation; +import com.cruru.util.RepositoryTest; +import com.cruru.util.fixture.EvaluationFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("평가 레포지토리 테스트") +class EvaluationRepositoryTest extends RepositoryTest { + + @Autowired + private EvaluationRepository evaluationRepository; + + @BeforeEach + void setUp() { + evaluationRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어 있는 ID를 가진 프로세스를 저장하면, 해당 ID의 프로세스는 후에 작성된 정보로 업데이트한다.") + @Test + void sameIdUpdate() { + //given + Evaluation evaluation = EvaluationFixture.fivePoints(); + Evaluation saved = evaluationRepository.save(evaluation); + + //when + Evaluation updatedEvaluation = new Evaluation(evaluation.getId(), 5, "포트폴리오가 인상 깊었습니다.", null, null); + evaluationRepository.save(updatedEvaluation); + + //then + Evaluation findEvaluation = evaluationRepository.findById(saved.getId()).get(); + assertThat(findEvaluation.getScore()).isEqualTo(5); + assertThat(findEvaluation.getContent()).isEqualTo("포트폴리오가 인상 깊었습니다."); + } + + @DisplayName("ID가 없는 프로세스를 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Evaluation evaluation1 = EvaluationFixture.fivePoints(); + Evaluation evaluation2 = EvaluationFixture.fourPoints(); + + //when + Evaluation savedEvaluation1 = evaluationRepository.save(evaluation1); + Evaluation savedEvaluation2 = evaluationRepository.save(evaluation2); + + //then + assertThat(savedEvaluation1.getId() + 1).isEqualTo(savedEvaluation2.getId()); + } +} diff --git a/backend/src/test/java/com/cruru/applicant/facade/ApplicantFacadeTest.java b/backend/src/test/java/com/cruru/applicant/facade/ApplicantFacadeTest.java new file mode 100644 index 000000000..bef2e0d0a --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/facade/ApplicantFacadeTest.java @@ -0,0 +1,193 @@ +package com.cruru.applicant.facade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.controller.request.ApplicantMoveRequest; +import com.cruru.applicant.controller.request.ApplicantUpdateRequest; +import com.cruru.applicant.controller.response.ApplicantAnswerResponses; +import com.cruru.applicant.controller.response.ApplicantBasicResponse; +import com.cruru.applicant.controller.response.ApplicantResponse; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.controller.response.ProcessSimpleResponse; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.question.controller.response.AnswerResponse; +import com.cruru.question.domain.Answer; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.AnswerRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.AnswerFixture; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.ProcessFixture; +import com.cruru.util.fixture.QuestionFixture; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("지원자 파사드 서비스 테스트") +class ApplicantFacadeTest extends ServiceTest { + + @Autowired + private ApplicantFacade applicantFacade; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private AnswerRepository answerRepository; + + @Autowired + private EntityManager entityManager; + + @DisplayName("Id로 지원자의 기본 정보를 조회한다.") + @Test + void readBasicById() { + // given + Process process = processRepository.save(ProcessFixture.applyType()); + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + + // when + ApplicantBasicResponse basicResponse = applicantFacade.readBasicById(applicant.getId()); + ProcessSimpleResponse processResponse = basicResponse.processResponse(); + ApplicantResponse applicantResponse = basicResponse.applicantResponse(); + + // then + assertAll( + () -> assertThat(processResponse.id()).isEqualTo(process.getId()), + () -> assertThat(processResponse.name()).isEqualTo(process.getName()), + () -> assertThat(applicantResponse.id()).isEqualTo(applicant.getId()), + () -> assertThat(applicantResponse.name()).isEqualTo(applicant.getName()), + () -> assertThat(applicantResponse.email()).isEqualTo(applicant.getEmail()), + () -> assertThat(applicantResponse.phone()).isEqualTo(applicant.getPhone()), + () -> assertThat(applicantResponse.isRejected()).isEqualTo(applicant.isRejected()) + ); + } + + @DisplayName("id로 지원자의 상세 정보를 찾는다.") + @Test + void readDetailById() { + // given + Dashboard dashboard = DashboardFixture.backend(); + dashboardRepository.save(dashboard); + Process process = ProcessFixture.applyType(dashboard); + processRepository.save(process); + Applicant applicant = ApplicantFixture.pendingDobby(process); + applicantRepository.save(applicant); + + Question question = questionRepository.save(QuestionFixture.shortAnswerType(null)); + questionRepository.save(question); + Answer answer = AnswerFixture.simple(question, applicant); + answerRepository.save(answer); + + // when + ApplicantAnswerResponses applicantAnswerResponses = applicantFacade.readDetailById(applicant.getId()); + + //then + List answerResponses = applicantAnswerResponses.answerResponses(); + assertAll( + () -> assertThat(answerResponses.get(0).question()).isEqualTo(question.getContent()), + () -> assertThat(answerResponses.get(0).answer()).isEqualTo(answer.getContent()) + ); + } + + @DisplayName("지원자의 이름, 이메일, 전화번호를 변경한다.") + @Test + void updateApplicantInformation() { + // given + Applicant applicant = ApplicantFixture.pendingDobby(); + String changedName = "수정된 이름"; + String changedEmail = "modified@email.com"; + String changedPhone = "01099999999"; + ApplicantUpdateRequest changeRequest = new ApplicantUpdateRequest(changedName, changedEmail, changedPhone); + Applicant savedApplicant = applicantRepository.save(applicant); + Long applicantId = savedApplicant.getId(); + + // when + applicantFacade.updateApplicantInformation(applicantId, changeRequest); + + // then + Applicant actualApplicant = applicantRepository.findById(applicantId).get(); + assertAll( + () -> assertThat(actualApplicant.getName()).isEqualTo(changedName), + () -> assertThat(actualApplicant.getEmail()).isEqualTo(changedEmail), + () -> assertThat(actualApplicant.getPhone()).isEqualTo(changedPhone) + ); + } + + @DisplayName("복수의 지원서들을 요청된 프로세스로 일괄 업데이트한다.") + @Test + void updateApplicantProcess() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + Process beforeProcess = processRepository.save(ProcessFixture.applyType(dashboard)); + Process afterProcess = processRepository.save(ProcessFixture.approveType(dashboard)); + + List applicants = applicantRepository.saveAll( + List.of( + ApplicantFixture.pendingDobby(beforeProcess), + ApplicantFixture.pendingDobby(beforeProcess) + )); + List applicantIds = applicants.stream() + .map(Applicant::getId) + .toList(); + ApplicantMoveRequest moveRequest = new ApplicantMoveRequest(applicantIds); + + // when + applicantFacade.updateApplicantProcess(afterProcess.getId(), moveRequest); + + // then + List actualApplicants = entityManager.createQuery( + "SELECT a FROM Applicant a JOIN FETCH a.process", + Applicant.class + ).getResultList(); + assertAll( + () -> assertThat(actualApplicants).isNotEmpty(), + () -> assertThat(actualApplicants).allMatch(applicant -> applicant.getProcess().equals(afterProcess)) + ); + } + + @DisplayName("특정 지원자를 불합격시킨다.") + @Test + void reject() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby()); + + // when + applicantFacade.reject(applicant.getId()); + + // then + Applicant rejectedApplicant = applicantRepository.findById(applicant.getId()).get(); + assertThat(rejectedApplicant.isRejected()).isTrue(); + } + + @DisplayName("특정 지원자의 불합격을 취소한다.") + @Test + void unreject() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.rejectedRush()); + + // when + applicantFacade.unreject(applicant.getId()); + + // then + Applicant unrejectedApplicant = applicantRepository.findById(applicant.getId()).get(); + assertThat(unrejectedApplicant.isNotRejected()).isTrue(); + } +} diff --git a/backend/src/test/java/com/cruru/applicant/facade/EvaluationFacadeTest.java b/backend/src/test/java/com/cruru/applicant/facade/EvaluationFacadeTest.java new file mode 100644 index 000000000..5e52debf5 --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/facade/EvaluationFacadeTest.java @@ -0,0 +1,106 @@ +package com.cruru.applicant.facade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.controller.request.EvaluationCreateRequest; +import com.cruru.applicant.controller.response.EvaluationResponse; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applicant.domain.repository.EvaluationRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.EvaluationFixture; +import com.cruru.util.fixture.ProcessFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("평가 파사드 서비스 테스트") +class EvaluationFacadeTest extends ServiceTest { + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private EvaluationRepository evaluationRepository; + + @Autowired + private EvaluationFacade evaluationFacade; + + private Process process; + + private Applicant applicant; + + @BeforeEach + void setUp() { + process = processRepository.save(ProcessFixture.applyType()); + applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + } + + @DisplayName("평가 등록 요청 정보로 평가를 생성한다.") + @Test + void create() { + // given + Evaluation evaluation = EvaluationFixture.fivePoints(); + Integer score = evaluation.getScore(); + String content = evaluation.getContent(); + EvaluationCreateRequest request = new EvaluationCreateRequest(score, content); + + // when + evaluationFacade.create(request, process.getId(), applicant.getId()); + + // then + List evaluations = evaluationRepository.findAllByProcessAndApplicant(process, applicant); + Evaluation actualEvaluation = evaluations.get(0); + assertAll( + () -> assertThat(evaluations).hasSize(1), + () -> assertThat(actualEvaluation.getScore()).isEqualTo(score), + () -> assertThat(actualEvaluation.getContent()).isEqualTo(content) + ); + } + + @DisplayName("특정 지원자의 해당 프로세스에서의 평가 내용을 조회한다.") + @Test + void readEvaluationsOfApplicantInProcess() { + // given + Evaluation evaluationExcellent = EvaluationFixture.fivePoints(process, applicant); + Evaluation evaluationGood = EvaluationFixture.fourPoints(process, applicant); + Evaluation evaluation1 = evaluationRepository.save(evaluationExcellent); + Evaluation evaluation2 = evaluationRepository.save(evaluationGood); + Evaluation savedEvaluation1 = evaluationRepository.findById(evaluation1.getId()).get(); + Evaluation savedEvaluation2 = evaluationRepository.findById(evaluation2.getId()).get(); + + // when + List evaluationResponses = evaluationFacade.readEvaluationsOfApplicantInProcess( + process.getId(), + applicant.getId() + ).evaluationsResponse(); + + // then + EvaluationResponse actualEvaluation1 = evaluationResponses.get(0); + EvaluationResponse actualEvaluation2 = evaluationResponses.get(1); + assertAll( + () -> assertThat(evaluationResponses).hasSize(2), + + () -> assertThat(actualEvaluation1.evaluationId()).isEqualTo(savedEvaluation1.getId()), + () -> assertThat(actualEvaluation1.content()).isEqualTo(savedEvaluation1.getContent()), + () -> assertThat(actualEvaluation1.score()).isEqualTo(savedEvaluation1.getScore()), + () -> assertThat(actualEvaluation1.createdDate()).isEqualTo(savedEvaluation1.getCreatedDate()), + + () -> assertThat(actualEvaluation2.evaluationId()).isEqualTo(savedEvaluation2.getId()), + () -> assertThat(actualEvaluation2.content()).isEqualTo(savedEvaluation2.getContent()), + () -> assertThat(actualEvaluation2.score()).isEqualTo(savedEvaluation2.getScore()), + () -> assertThat(actualEvaluation2.createdDate()).isEqualTo(savedEvaluation2.getCreatedDate()) + ); + + } +} diff --git a/backend/src/test/java/com/cruru/applicant/service/ApplicantServiceTest.java b/backend/src/test/java/com/cruru/applicant/service/ApplicantServiceTest.java new file mode 100644 index 000000000..323b8be17 --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/service/ApplicantServiceTest.java @@ -0,0 +1,202 @@ +package com.cruru.applicant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.controller.request.ApplicantCreateRequest; +import com.cruru.applicant.controller.request.ApplicantMoveRequest; +import com.cruru.applicant.controller.request.ApplicantUpdateRequest; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applicant.exception.ApplicantNotFoundException; +import com.cruru.applicant.exception.badrequest.ApplicantRejectException; +import com.cruru.applicant.exception.badrequest.ApplicantUnrejectException; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.ProcessFixture; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("지원자 서비스 테스트") +class ApplicantServiceTest extends ServiceTest { + + @Autowired + private ApplicantService applicantService; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private EntityManager entityManager; + + @DisplayName("지원자를 정상적으로 저장한다.") + @Test + void create() { + // given + Process firstProcess = processRepository.save(ProcessFixture.applyType()); + String name = "도비"; + String email = "kimdobby@email.com"; + String phone = "01052525252"; + ApplicantCreateRequest createRequest = new ApplicantCreateRequest(name, email, phone); + + // when + Applicant createdApplicant = applicantService.create(createRequest, firstProcess); + + // then + Applicant actualApplicant = applicantRepository.findById(createdApplicant.getId()).get(); + assertAll( + () -> assertThat(actualApplicant.getName()).isEqualTo(name), + () -> assertThat(actualApplicant.getEmail()).isEqualTo(email), + () -> assertThat(actualApplicant.getPhone()).isEqualTo(phone) + ); + } + + @DisplayName("프로세스 내의 모든 지원자를 조회한다.") + @Test + void findAllByProcess() { + // given + Process process = processRepository.save(ProcessFixture.applyType()); + Applicant applicant1 = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + Applicant applicant2 = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + Applicant applicant3 = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + List applicants = List.of(applicant1, applicant2, applicant3); + + // when + List applicantsInProcess = applicantService.findAllByProcess(process); + + // then + assertThat(applicantsInProcess).containsExactlyElementsOf(applicants); + } + + @DisplayName("id에 해당하는 지원자가 존재하지 않으면 Not Found 예외가 발생한다.") + @Test + void findById_notFound() { + // given + long invalidId = -1L; + + // when&then + assertThatThrownBy(() -> applicantService.findById(invalidId)) + .isInstanceOf(ApplicantNotFoundException.class); + } + + @DisplayName("지원자의 이름, 이메일, 전화번호 변경 요청시, 정보를 업데이트한다") + @Test + void updateApplicantInformation() { + // given + Applicant applicant = ApplicantFixture.pendingDobby(); + String changedName = "수정된 이름"; + String changedEmail = "modified@email.com"; + String changedPhone = "01012341234"; + ApplicantUpdateRequest updateRequest = new ApplicantUpdateRequest(changedName, changedEmail, changedPhone); + Applicant savedApplicant = applicantRepository.save(applicant); + + // when + applicantService.updateApplicantInformation(savedApplicant.getId(), updateRequest); + + // then + Applicant updatedApplicant = applicantRepository.findById(savedApplicant.getId()).get(); + assertAll( + () -> assertThat(changedName).isEqualTo(updatedApplicant.getName()), + () -> assertThat(changedEmail).isEqualTo(updatedApplicant.getEmail()), + () -> assertThat(changedPhone).isEqualTo(updatedApplicant.getPhone()) + ); + } + + @DisplayName("여러 건의 지원서를 요청된 프로세스로 일괄 변경한다.") + @Test + void moveApplicantProcess() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + Process beforeProcess = processRepository.save(ProcessFixture.applyType(dashboard)); + Process afterProcess = processRepository.save(ProcessFixture.approveType(dashboard)); + + List applicants = applicantRepository.saveAll(List.of( + ApplicantFixture.pendingDobby(beforeProcess), + ApplicantFixture.pendingDobby(beforeProcess) + )); + List applicantIds = applicants.stream() + .map(Applicant::getId) + .toList(); + ApplicantMoveRequest moveRequest = new ApplicantMoveRequest(applicantIds); + + // when + applicantService.moveApplicantProcess(afterProcess, moveRequest); + + // then + List actualApplicants = entityManager.createQuery( + "SELECT a FROM Applicant a JOIN FETCH a.process", + Applicant.class + ).getResultList(); + assertAll( + () -> assertThat(actualApplicants).isNotEmpty(), + () -> assertThat(actualApplicants).allMatch(applicant -> applicant.getProcess().equals(afterProcess)) + ); + } + + @DisplayName("특정 지원자의 상태를 불합격으로 변경한다.") + @Test + void reject() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby()); + + // when + applicantService.reject(applicant.getId()); + + // then + Applicant rejectedApplicant = applicantRepository.findById(applicant.getId()).get(); + assertThat(rejectedApplicant.isRejected()).isTrue(); + } + + @DisplayName("이미 불합격한 지원자를 불합격시키려 하면 예외가 발생한다.") + @Test + void reject_alreadyRejected() { + // given + Applicant rejectedApplicant = applicantRepository.save(ApplicantFixture.rejectedRush()); + + // when&then + Long applicantId = rejectedApplicant.getId(); + assertThatThrownBy(() -> applicantService.reject(applicantId)) + .isInstanceOf(ApplicantRejectException.class); + } + + @DisplayName("특정 지원자의 불합격을 취소한다.") + @Test + void unreject() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.rejectedRush()); + + // when + applicantService.unreject(applicant.getId()); + + // then + Applicant unrejectedApplicant = applicantRepository.findById(applicant.getId()).get(); + assertThat(unrejectedApplicant.isNotRejected()).isTrue(); + } + + @DisplayName("불합격이 아닌 지원자의 불합격을 취소하면 예외가 발생한다.") + @Test + void unreject_notRejected() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingRush()); + + // when&then + Long applicantId = applicant.getId(); + assertThatThrownBy(() -> applicantService.unreject(applicantId)) + .isInstanceOf(ApplicantUnrejectException.class); + } +} diff --git a/backend/src/test/java/com/cruru/applicant/service/EvaluationServiceTest.java b/backend/src/test/java/com/cruru/applicant/service/EvaluationServiceTest.java new file mode 100644 index 000000000..92e7a33d6 --- /dev/null +++ b/backend/src/test/java/com/cruru/applicant/service/EvaluationServiceTest.java @@ -0,0 +1,114 @@ +package com.cruru.applicant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.controller.request.EvaluationCreateRequest; +import com.cruru.applicant.controller.request.EvaluationUpdateRequest; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applicant.domain.repository.EvaluationRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.EvaluationFixture; +import com.cruru.util.fixture.ProcessFixture; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("평가 서비스 테스트") +class EvaluationServiceTest extends ServiceTest { + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private EvaluationRepository evaluationRepository; + + @Autowired + private EvaluationService evaluationService; + + private Process process; + + private Applicant applicant; + + @BeforeEach + void setUp() { + process = processRepository.save(ProcessFixture.applyType()); + + applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + } + + @DisplayName("새로운 평가를 생성한다.") + @Test + void create() { + // given + int score = 4; + String content = "서류가 인상적입니다."; + + // when + EvaluationCreateRequest request = new EvaluationCreateRequest(score, content); + evaluationService.create(request, process, applicant); + + // then + List evaluations = evaluationRepository.findAllByProcessAndApplicant(process, applicant); + Evaluation evaluation = evaluations.get(0); + assertAll( + () -> assertThat(evaluations).hasSize(1), + () -> assertThat(evaluation.getScore()).isEqualTo(score), + () -> assertThat(evaluation.getContent()).isEqualTo(content) + ); + } + + @DisplayName("지원자와 프로세스를 통해 평가를 조회한다.") + @Test + void findAllByProcessAndApplicant() { + // given + int score = 1; + String content = "인재상과 맞지 않습니다."; + Evaluation evaluation = evaluationRepository.save(new Evaluation(score, content, process, applicant)); + + // when + List savedEvaluations = evaluationService.findAllByProcessAndApplicant(process, applicant); + + // then + Evaluation actualEvaluation = savedEvaluations.get(0); + assertAll( + () -> assertThat(savedEvaluations).hasSize(1), + () -> assertThat(actualEvaluation.getId()).isEqualTo(evaluation.getId()), + () -> assertThat(actualEvaluation.getScore()).isEqualTo(score), + () -> assertThat(actualEvaluation.getContent()).isEqualTo(content) + ); + } + + @DisplayName("평가 수정에 성공한다.") + @Test + void update() { + // given + Evaluation evaluation = evaluationRepository.save(EvaluationFixture.fivePoints()); + int score = 1; + String content = "수정된 평가입니다."; + EvaluationUpdateRequest request = new EvaluationUpdateRequest(score, content); + + // when + evaluationService.update(request, evaluation); + + // then + Optional updatedEvaluation = evaluationRepository.findById(evaluation.getId()); + + assertAll( + () -> assertThat(updatedEvaluation).isPresent(), + () -> assertThat(updatedEvaluation.get().getScore()).isEqualTo(score), + () -> assertThat(updatedEvaluation.get().getContent()).isEqualTo(content) + ); + } +} diff --git a/backend/src/test/java/com/cruru/applyform/controller/ApplyFormControllerTest.java b/backend/src/test/java/com/cruru/applyform/controller/ApplyFormControllerTest.java new file mode 100644 index 000000000..08fd8574c --- /dev/null +++ b/backend/src/test/java/com/cruru/applyform/controller/ApplyFormControllerTest.java @@ -0,0 +1,480 @@ +package com.cruru.applyform.controller; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +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.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.applicant.controller.request.ApplicantCreateRequest; +import com.cruru.applyform.controller.request.AnswerCreateRequest; +import com.cruru.applyform.controller.request.ApplyFormSubmitRequest; +import com.cruru.applyform.controller.request.ApplyFormWriteRequest; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.ChoiceRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.util.ControllerTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.ChoiceFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.LocalDateFixture; +import com.cruru.util.fixture.ProcessFixture; +import com.cruru.util.fixture.QuestionFixture; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.restdocs.payload.FieldDescriptor; + +@DisplayName("지원서 폼 컨트롤러 테스트") +class ApplyFormControllerTest extends ControllerTest { + + private static final FieldDescriptor[] ANSWER_SUBMIT_FIELD_DESCRIPTORS = { + fieldWithPath("questionId").description("질문의 id"), + fieldWithPath("replies").description("질문에 대한 응답") + }; + + private static final FieldDescriptor[] QUESTION_FIELD_DESCRIPTORS = { + fieldWithPath("id").description("질문의 id"), + fieldWithPath("type").description("질문 유형"), + fieldWithPath("label").description("질문의 내용"), + fieldWithPath("orderIndex").description("질문 순서"), + fieldWithPath("choices").description("질문의 선택지"), + fieldWithPath("required").description("질문 필수여부"), + }; + + private static final FieldDescriptor[] CHOICE_FIELD_DESCRIPTORS = { + fieldWithPath("id").description("선택지의 id"), + fieldWithPath("label").description("선택지의 내용"), + fieldWithPath("orderIndex").description("선택지 순서") + }; + + private static final FieldDescriptor[] APPLICANT_SUBMIT_FIELD_DESCRIPTORS = { + fieldWithPath("applicant.name").description("지원자의 이름"), + fieldWithPath("applicant.email").description("지원자의 이메일"), + fieldWithPath("applicant.phone").description("지원자의 전화번호"), + fieldWithPath("answers").description("지원폼에 대한 응답 모음"), + fieldWithPath("personalDataCollection").description("개인정보 활용 동의 여부") + }; + + private static final FieldDescriptor[] APPLYFORM_WRITE_FIELD_DESCRIPTORS = { + fieldWithPath("title").description("지원폼 제목"), + fieldWithPath("postingContent").description("지원폼 내용(본문)"), + fieldWithPath("startDate").description("지원 시작 날짜"), + fieldWithPath("endDate").description("지원 마감 날짜") + }; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private ChoiceRepository choiceRepository; + + private static Stream InvalidApplicantCreateRequest() { + String validName = "초코칩"; + String validMail = "dev.chocochip@gmail.com"; + String validPhone = "01000000000"; + return Stream.of( + new ApplicantCreateRequest(null, validMail, validPhone), + new ApplicantCreateRequest("", validMail, validPhone), + new ApplicantCreateRequest(validName, null, validPhone), + new ApplicantCreateRequest(validName, "", validPhone), + new ApplicantCreateRequest(validName, "notMail", validPhone), + new ApplicantCreateRequest(validName, validMail, null), + new ApplicantCreateRequest(validName, validMail, "") + ); + } + + @DisplayName("지원서 폼 제출 시, 201을 반환한다.") + @Test + void submit() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + processRepository.save(ProcessFixture.applyType(dashboard)); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.frontend(dashboard)); + Question question1 = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + Question question2 = questionRepository.save(QuestionFixture.longAnswerType(applyForm)); + + List answerCreateRequests = List.of( + new AnswerCreateRequest(question1.getId(), List.of("안녕하세요, 맛있는 초코칩입니다.")), + new AnswerCreateRequest(question2.getId(), List.of("온라인")) + ); + ApplyFormSubmitRequest request = new ApplyFormSubmitRequest( + new ApplicantCreateRequest("초코칩", "dev.chocochip@gmail.com", "01000000000"), + answerCreateRequests, + true + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("applyform/submit", + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + requestFields(APPLICANT_SUBMIT_FIELD_DESCRIPTORS) + .andWithPrefix("answers[].", ANSWER_SUBMIT_FIELD_DESCRIPTORS) + )) + .when().post("/v1/applyform/{applyFormId}/submit", applyForm.getId()) + .then().log().all().statusCode(201); + } + + @DisplayName("지원서 폼 제출 시, 개인정보 활용을 거부할 경우 400을 반환한다.") + @Test + void submit_rejectPersonalDataCollection() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + processRepository.save(ProcessFixture.applyType(dashboard)); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.frontend(dashboard)); + Question question1 = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + Question question2 = questionRepository.save(QuestionFixture.longAnswerType(applyForm)); + + List answerCreateRequests = List.of( + new AnswerCreateRequest(question1.getId(), List.of("안녕하세요, 맛있는 초코칩입니다.")), + new AnswerCreateRequest(question2.getId(), List.of("온라인")) + ); + ApplyFormSubmitRequest request = new ApplyFormSubmitRequest( + new ApplicantCreateRequest("초코칩", "dev.chocochip@gmail.com", "01000000000"), + answerCreateRequests, + false + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("applicant/submit-fail/reject-personal-data-collection", + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + requestFields( + fieldWithPath("applicant.name").description("지원자의 이름"), + fieldWithPath("applicant.email").description("지원자의 이메일"), + fieldWithPath("applicant.phone").description("지원자의 전화번호"), + fieldWithPath("answers").description("지원폼에 대한 응답 모음"), + fieldWithPath("personalDataCollection").description("개인정보 활용 동의 거부") + ).andWithPrefix("answers[].", ANSWER_SUBMIT_FIELD_DESCRIPTORS) + )) + .when().post("/v1/applyform/{applyFormId}/submit", applyForm.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("지원서 폼 제출 시, 지원자 정보가 잘못된 경우 400 에러가 발생한다.") + @ParameterizedTest + @MethodSource("InvalidApplicantCreateRequest") + void submit_invalidApplicantCreateRequest(ApplicantCreateRequest applicantCreateRequest) { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + processRepository.save(ProcessFixture.applyType(dashboard)); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.frontend(dashboard)); + Question question1 = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + + List answerCreateRequests = List.of( + new AnswerCreateRequest(question1.getId(), List.of("안녕하세요, 맛있는 초코칩입니다.")) + ); + ApplyFormSubmitRequest request = new ApplyFormSubmitRequest( + applicantCreateRequest, + answerCreateRequests, + true + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("applicant/submit-fail/invalid-applicant-info", + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + requestFields(APPLICANT_SUBMIT_FIELD_DESCRIPTORS) + .andWithPrefix("answers[].", ANSWER_SUBMIT_FIELD_DESCRIPTORS) + )) + .when().post("/v1/applyform/{applyFormId}/submit", applyForm.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("지원서 폼 제출 시, 지원자 답변이 잘못된 경우 400 에러가 발생한다.") + @Test + void submit_invalidAnswerCreateRequests() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + processRepository.save(ProcessFixture.applyType(dashboard)); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.frontend(dashboard)); + Question question1 = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + + List answerCreateRequests = List.of( + new AnswerCreateRequest(question1.getId(), null) + ); + ApplyFormSubmitRequest request = new ApplyFormSubmitRequest( + new ApplicantCreateRequest("초코칩", "dev.chocochip@gmail.com", "01000000000"), + answerCreateRequests, + true + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("applicant/submit-fail/invalid-answers", + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + requestFields(APPLICANT_SUBMIT_FIELD_DESCRIPTORS) + .andWithPrefix("answers[].", ANSWER_SUBMIT_FIELD_DESCRIPTORS) + )) + .when().post("/v1/applyform/{applyFormId}/submit", applyForm.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("지원서 폼 제출 시, 대시보드에 제출 프로세스가 존재하지 않으면 500을 반환한다.") + @Test + void submit_dashboardWithNoSubmitProcess() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.frontend(dashboard)); + Question question = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + + ApplyFormSubmitRequest request = new ApplyFormSubmitRequest( + new ApplicantCreateRequest("초코칩", "dev.chocochip@gmail.com", "01000000000"), + List.of(new AnswerCreateRequest(question.getId(), List.of("온라인"))), + true + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("applicant/submit-fail/no-submit-process", + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + requestFields(APPLICANT_SUBMIT_FIELD_DESCRIPTORS). + andWithPrefix("answers[].", ANSWER_SUBMIT_FIELD_DESCRIPTORS) + )) + .when().post("/v1/applyform/{applyFormId}/submit", applyForm.getId()) + .then().log().all().statusCode(500); + } + + @DisplayName("지원서 폼 제출 시, 모집 기간을 벗어난 경우 400을 반환한다.") + @Test + void submit_dateOutOfRange() { + // given + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.notStarted()); + Question question = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + + ApplyFormSubmitRequest request = new ApplyFormSubmitRequest( + new ApplicantCreateRequest("초코칩", "dev.chocochip@gmail.com", "01000000000"), + List.of(new AnswerCreateRequest(question.getId(), List.of("온라인"))), + true + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("applicant/submit-fail/date-out-of-range", + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + requestFields(APPLICANT_SUBMIT_FIELD_DESCRIPTORS) + .andWithPrefix("answers[].", ANSWER_SUBMIT_FIELD_DESCRIPTORS) + )) + .when().post("/v1/applyform/{applyFormId}/submit", applyForm.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("지원서 폼 제출 시, 지원서 폼이 존재하지 않을 경우 404를 반환한다.") + @Test + void submit_applyFormNotFound() { + // given + int invalidApplyFormId = -1; + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.notStarted()); + Question question = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + + ApplyFormSubmitRequest request = new ApplyFormSubmitRequest( + new ApplicantCreateRequest("초코칩", "dev.chocochip@gmail.com", "01000000000"), + List.of(new AnswerCreateRequest(question.getId(), List.of("온라인"))), + true + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("applicant/submit-fail/applyform-not-found", + pathParameters(parameterWithName("applyFormId").description("존재하지 않는 지원폼의 id")), + requestFields(APPLICANT_SUBMIT_FIELD_DESCRIPTORS) + .andWithPrefix("answers[].", ANSWER_SUBMIT_FIELD_DESCRIPTORS) + )) + .when().post("/v1/applyform/{applyFormId}/submit", invalidApplyFormId) + .then().log().all().statusCode(404); + } + + @DisplayName("지원서 폼 제출 시, 질문이 존재하지 않을 경우 400를 반환한다.") + @Test + void submit_questionNotFound() { + // given + long invalidQuestionId = -1; + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.notStarted()); + + ApplyFormSubmitRequest request = new ApplyFormSubmitRequest( + new ApplicantCreateRequest("초코칩", "dev.chocochip@gmail.com", "01000000000"), + List.of(new AnswerCreateRequest(invalidQuestionId, List.of("온라인"))), + true + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("applicant/submit-fail/question-not-found", + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + requestFields(APPLICANT_SUBMIT_FIELD_DESCRIPTORS) + .andWithPrefix("answers[].", + fieldWithPath("questionId").description("존재하지 않는 질문의 id"), + fieldWithPath("replies").description("질문에 대한 응답") + ) + )) + .when().post("/v1/applyform/{applyFormId}/submit", applyForm.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("지원서 폼 제출 시, 필수 질문에 응답하지 않은 경우 400를 반환한다.") + @Test + void submit_RequiredNotReplied() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + processRepository.save(ProcessFixture.applyType(dashboard)); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + questionRepository.save(QuestionFixture.required(applyForm)); + + ApplyFormSubmitRequest request = new ApplyFormSubmitRequest( + new ApplicantCreateRequest("초코칩", "dev.chocochip@gmail.com", "01000000000"), + List.of(), + true + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("applicant/submit-fail/required-not-replied", + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + requestFields(APPLICANT_SUBMIT_FIELD_DESCRIPTORS) + )) + .when().post("/v1/applyform/{applyFormId}/submit", applyForm.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("지원서 폼 조회 시, 200을 반환한다.") + @Test + void read() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + processRepository.save(ProcessFixture.applyType(dashboard)); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + Question question1 = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + Question question2 = questionRepository.save(QuestionFixture.multipleChoiceType(applyForm)); + choiceRepository.saveAll(ChoiceFixture.fiveChoices(question2)); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .filter(document("applicant/read-applyform", + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + responseFields( + fieldWithPath("title").description("지원폼의 제목"), + fieldWithPath("postingContent").description("지원자의 내용(본문)"), + fieldWithPath("startDate").description("지원 가능 날짜"), + fieldWithPath("endDate").description("지원 마감 날짜"), + fieldWithPath("questions").description("지원폼의 질문들") + ).andWithPrefix("questions[].", QUESTION_FIELD_DESCRIPTORS) + .andWithPrefix("questions[].choices[].", CHOICE_FIELD_DESCRIPTORS) + )) + .when().get("/v1/applyform/{applyFormId}", applyForm.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("지원서 폼 조회 시, 지원서 폼이 존재하지 않을 경우 404을 반환한다.") + @Test + void read_notFound() { + // given + int invalidApplyFormId = -1; + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .cookie("token", token) + .filter(document("applicant/read-applyform-fail/applyform-not-found", + pathParameters(parameterWithName("applyFormId").description("존재하지 않는 지원폼의 id")) + )) + .when().get("/v1/applyform/{applyFormId}", invalidApplyFormId) + .then().log().all().statusCode(404); + } + + @DisplayName("지원서 폼을 성공적으로 수정하면, 200을 응답한다.") + @Test + void update() { + // given + String toChangeTitle = "크루루 백엔드 모집 공고~~"; + String toChangeDescription = "# 모집 공고 설명 #"; + LocalDateTime toChangeStartDate = LocalDateFixture.oneDayLater(); + LocalDateTime toChangeEndDate = LocalDateFixture.oneWeekLater(); + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + ApplyFormWriteRequest request = new ApplyFormWriteRequest( + toChangeTitle, toChangeDescription, toChangeStartDate, toChangeEndDate + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .cookie("token", token) + .body(request) + .filter(document("applicant/update", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("applyFormId").description("지원폼의 id")), + requestFields(APPLYFORM_WRITE_FIELD_DESCRIPTORS) + )) + .when().patch("/v1/applyform/{applyFormId}", applyForm.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("지원서 폼 변경 시, 지원서 폼이 존재하지 않을 경우 404을 반환한다.") + @Test + void update_notFound() { + // given + int invalidApplyFormId = -1; + String toChangeTitle = "크루루 백엔드 모집 공고~~"; + String toChangeDescription = "# 모집 공고 설명 #"; + LocalDateTime toChangeStartDate = LocalDateFixture.oneDayLater(); + LocalDateTime toChangeEndDate = LocalDateFixture.oneWeekLater(); + ApplyFormWriteRequest request = new ApplyFormWriteRequest( + toChangeTitle, toChangeDescription, toChangeStartDate, toChangeEndDate + ); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .cookie("token", token) + .body(request) + .filter(document("applicant/update-fail/applyform-not-found", + pathParameters(parameterWithName("applyFormId").description("존재하지 않는 지원폼의 id")), + requestFields(APPLYFORM_WRITE_FIELD_DESCRIPTORS) + )) + .when().patch("/v1/applyform/{applyFormId}", invalidApplyFormId) + .then().log().all().statusCode(404); + } +} diff --git a/backend/src/test/java/com/cruru/applyform/domain/ApplyFormTest.java b/backend/src/test/java/com/cruru/applyform/domain/ApplyFormTest.java new file mode 100644 index 000000000..c0076771f --- /dev/null +++ b/backend/src/test/java/com/cruru/applyform/domain/ApplyFormTest.java @@ -0,0 +1,27 @@ +package com.cruru.applyform.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.cruru.applyform.exception.badrequest.StartDateAfterEndDateException; +import com.cruru.util.fixture.LocalDateFixture; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("지원서 폼 도메인 테스트") +class ApplyFormTest { + + @DisplayName("시작 날짜가 마감 날짜보다 늦을 경우 예외가 발생한다.") + @Test + void invalidDate() { + // given + String title = "title"; + String description = "description"; + LocalDateTime startDate = LocalDateFixture.oneWeekLater(); + LocalDateTime endDate = LocalDateFixture.oneDayLater(); + + // when&then + assertThatThrownBy(() -> new ApplyForm(title, description, startDate, endDate, null)) + .isInstanceOf(StartDateAfterEndDateException.class); + } +} diff --git a/backend/src/test/java/com/cruru/applyform/domain/repository/ApplyFormRepositoryTest.java b/backend/src/test/java/com/cruru/applyform/domain/repository/ApplyFormRepositoryTest.java new file mode 100644 index 000000000..fe28c6d23 --- /dev/null +++ b/backend/src/test/java/com/cruru/applyform/domain/repository/ApplyFormRepositoryTest.java @@ -0,0 +1,119 @@ +package com.cruru.applyform.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.club.domain.Club; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.DashboardApplyFormDto; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.util.RepositoryTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.ClubFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.LocalDateFixture; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("지원서 레포지토리 테스트") +class ApplyFormRepositoryTest extends RepositoryTest { + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ClubRepository clubRepository; + + @BeforeEach + void setUp() { + applyFormRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어있는 ID를 가진 지원서를 저장하면, 해당 ID의 지원서는 가장 최근 저장된 정보로 업데이트된다.") + @Test + void save_ApplyFormIdUpdate() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + ApplyForm applyForm = ApplyFormFixture.backend(dashboard); + ApplyForm initialApplyForm = applyFormRepository.save(applyForm); + + // when + String title = "수정된 공고 제목"; + String description = "수정된 상세 내용"; + LocalDateTime startDate = LocalDateFixture.oneDayLater(); + LocalDateTime endDate = LocalDateFixture.oneWeekLater(); + + ApplyForm expectedApplyForm = applyFormRepository.save(new ApplyForm( + initialApplyForm.getId(), + title, + description, + startDate, + endDate, + initialApplyForm.getDashboard() + )); + + // then + ApplyForm actualApplyForm = applyFormRepository.findById(expectedApplyForm.getId()).get(); + assertAll( + () -> assertThat(actualApplyForm.getDashboard()).isEqualTo(applyForm.getDashboard()), + () -> assertThat(actualApplyForm.getTitle()).isEqualTo(title), + () -> assertThat(actualApplyForm.getDescription()).isEqualTo(description), + () -> assertThat(actualApplyForm.getStartDate()).isEqualTo(startDate), + () -> assertThat(actualApplyForm.getEndDate()).isEqualTo(endDate) + ); + } + + @DisplayName("ID가 없는 지원서 양식을 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void save_NotSavedId() { + //given + Dashboard dashboard1 = dashboardRepository.save(DashboardFixture.backend()); + Dashboard dashboard2 = dashboardRepository.save(DashboardFixture.frontend()); + ApplyForm applyForm1 = ApplyFormFixture.backend(dashboard1); + ApplyForm applyForm2 = ApplyFormFixture.frontend(dashboard2); + + //when + ApplyForm savedApplyForm1 = applyFormRepository.save(applyForm1); + ApplyForm savedApplyForm2 = applyFormRepository.save(applyForm2); + + //then + assertThat(savedApplyForm1.getId() + 1).isEqualTo(savedApplyForm2.getId()); + } + + @DisplayName("특정 동아리에 속하는 DashboardApplyForm 목록을 반환한다.") + @Test + void findAllByClub() { + // given + Club club = clubRepository.save(ClubFixture.create()); + Dashboard dashboard1 = dashboardRepository.save(DashboardFixture.frontend(club)); + Dashboard dashboard2 = dashboardRepository.save(DashboardFixture.backend(club)); + + ApplyForm applyForm1 = applyFormRepository.save(ApplyFormFixture.frontend(dashboard1)); + ApplyForm applyForm2 = applyFormRepository.save(ApplyFormFixture.backend(dashboard2)); + + // when + List dashboardApplyFormDtos = applyFormRepository.findAllByClub(club.getId()); + + // then + assertThat(dashboardApplyFormDtos).hasSize(2); + + DashboardApplyFormDto dto1 = dashboardApplyFormDtos.get(0); + DashboardApplyFormDto dto2 = dashboardApplyFormDtos.get(1); + + assertAll( + () -> assertThat(dto1.dashboard().getId()).isEqualTo(dashboard1.getId()), + () -> assertThat(dto1.applyForm().getId()).isEqualTo(applyForm1.getId()), + () -> assertThat(dto2.dashboard().getId()).isEqualTo(dashboard2.getId()), + () -> assertThat(dto2.applyForm().getId()).isEqualTo(applyForm2.getId()) + ); + } +} diff --git a/backend/src/test/java/com/cruru/applyform/facade/ApplyFormFacadeTest.java b/backend/src/test/java/com/cruru/applyform/facade/ApplyFormFacadeTest.java new file mode 100644 index 000000000..80bca4a48 --- /dev/null +++ b/backend/src/test/java/com/cruru/applyform/facade/ApplyFormFacadeTest.java @@ -0,0 +1,218 @@ +package com.cruru.applyform.facade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.advice.InternalServerException; +import com.cruru.applicant.controller.request.ApplicantCreateRequest; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applyform.controller.request.AnswerCreateRequest; +import com.cruru.applyform.controller.request.ApplyFormSubmitRequest; +import com.cruru.applyform.controller.request.ApplyFormWriteRequest; +import com.cruru.applyform.controller.response.ApplyFormResponse; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.applyform.exception.ApplyFormNotFoundException; +import com.cruru.applyform.exception.badrequest.ApplyFormSubmitOutOfPeriodException; +import com.cruru.applyform.exception.badrequest.PersonalDataCollectDisagreeException; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.question.controller.response.QuestionResponse; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.AnswerRepository; +import com.cruru.question.domain.repository.ChoiceRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.LocalDateFixture; +import com.cruru.util.fixture.ProcessFixture; +import com.cruru.util.fixture.QuestionFixture; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("지원서폼 파사드 서비스 테스트") +class ApplyFormFacadeTest extends ServiceTest { + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private AnswerRepository answerRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private ApplyFormFacade applyFormFacade; + + private Process firstProcess; + private Process finalProcess; + private ApplyForm applyForm; + private Question question1; + private List answerCreateRequests; + private ApplyFormSubmitRequest applyFormSubmitrequest; + private ApplicantCreateRequest applicantCreateRequest; + + @BeforeEach + void setUp() { + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend(null)); + firstProcess = processRepository.save(ProcessFixture.applyType(dashboard)); + finalProcess = processRepository.save(ProcessFixture.approveType(dashboard)); + applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + question1 = questionRepository.save(QuestionFixture.longAnswerType(applyForm)); + Question question2 = questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + + answerCreateRequests = List.of( + new AnswerCreateRequest(question1.getId(), List.of("안녕하세요, 첫 번째 답변입니다")), + new AnswerCreateRequest(question2.getId(), List.of("온라인")) + ); + + applicantCreateRequest = new ApplicantCreateRequest( + "초코칩", + "dev.chocochip@gmail.com", + "01000000000" + ); + + applyFormSubmitrequest = new ApplyFormSubmitRequest(applicantCreateRequest, answerCreateRequests, true); + } + + @DisplayName("지원서 폼 제출에 성공한다.") + @Test + void submit() { + // given&when + applyFormFacade.submit(applyForm.getId(), applyFormSubmitrequest); + + // then + assertAll( + () -> assertThat(answerRepository.findAll()).hasSize(answerCreateRequests.size()), + () -> assertThat(applicantRepository.countByProcess(firstProcess)).isEqualTo(1), + () -> assertThat(applicantRepository.countByProcess(finalProcess)).isZero() + ); + } + + @DisplayName("지원서 폼 제출 시, 대시보드에 프로세스가 존재하지 않으면 예외가 발생한다.") + @Test + void submit_dashboardWithNoProcess() { + // given + Dashboard emptyProcessDashboard = dashboardRepository.save(DashboardFixture.backend(null)); + ApplyForm emptyProcessApplyForm = applyFormRepository.save(ApplyFormFixture.backend(emptyProcessDashboard)); + + // when&then + Long emptyProcessApplyFormId = emptyProcessApplyForm.getId(); + assertThatThrownBy(() -> applyFormFacade.submit(emptyProcessApplyFormId, applyFormSubmitrequest)) + .isInstanceOf(InternalServerException.class); + } + + @DisplayName("지원서 폼 제출 시, 개인정보수집에 동의하지 않은 요청은 예외가 발생한다.") + @Test + void submit_rejectPersonalDataCollection() { + // given + ApplyFormSubmitRequest notAgreedRequest = new ApplyFormSubmitRequest( + applicantCreateRequest, + answerCreateRequests, + false + ); + + // when&then + Long applyFormId = applyForm.getId(); + assertThatThrownBy(() -> applyFormFacade.submit(applyFormId, notAgreedRequest)) + .isInstanceOf(PersonalDataCollectDisagreeException.class); + } + + @DisplayName("지원서 폼 제출 시, 지원 날짜 범위 밖이면 예외가 발생한다.") + @Test + void submit_invalidSubmitDate() { + // given + ApplyForm pastApplyForm = applyFormRepository.save(new ApplyForm( + "지난 모집 공고", "description", + LocalDateFixture.oneWeekAgo(), LocalDateFixture.oneDayAgo(), null + )); + ApplyForm futureApplyForm = applyFormRepository.save(new ApplyForm( + "미래의 모집 공고", "description", + LocalDateFixture.oneDayLater(), LocalDateFixture.oneWeekLater(), null + )); + + // when&then + assertAll( + () -> assertThatThrownBy(() -> applyFormFacade.submit(pastApplyForm.getId(), applyFormSubmitrequest)) + .isInstanceOf(ApplyFormSubmitOutOfPeriodException.class), + () -> assertThatThrownBy(() -> applyFormFacade.submit(futureApplyForm.getId(), applyFormSubmitrequest)) + .isInstanceOf(ApplyFormSubmitOutOfPeriodException.class) + ); + } + + @DisplayName("지원서 폼 제출 시, 지원서 폼이 존재하지 않을 경우 예외가 발생한다.") + @Test + void submit_invalidApplyForm() { + // given&when&then + assertThatThrownBy(() -> applyFormFacade.submit(-1, applyFormSubmitrequest)) + .isInstanceOf(ApplyFormNotFoundException.class); + } + + @DisplayName("지원서 폼을 수정한다.") + @Test + void update() { + // given + String toChangeTitle = "크루루 백엔드 모집 공고~~"; + String toChangeDescription = "# 모집 공고 설명 #"; + LocalDateTime toChangeStartDate = LocalDateFixture.oneDayLater(); + LocalDateTime toChangeEndDate = LocalDateFixture.oneWeekLater(); + + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + ApplyFormWriteRequest request = new ApplyFormWriteRequest( + toChangeTitle, toChangeDescription, toChangeStartDate, toChangeEndDate + ); + + // when + applyFormFacade.update(request, applyForm.getId()); + + // then + ApplyForm actual = applyFormRepository.findById(applyForm.getId()).get(); + assertAll( + () -> assertThat(actual.getTitle()).isEqualTo(toChangeTitle), + () -> assertThat(actual.getDescription()).isEqualTo(toChangeDescription), + () -> assertThat(actual.getStartDate()).isEqualTo(toChangeStartDate), + () -> assertThat(actual.getEndDate()).isEqualTo(toChangeEndDate) + ); + } + + @DisplayName("지원서 폼 조회에 성공한다.") + @Test + void readApplyFormById() { + // given&when + ApplyFormResponse applyFormResponse = applyFormFacade.readApplyFormById(applyForm.getId()); + + // then + assertAll( + () -> assertThat(applyFormResponse.title()).isEqualTo(applyForm.getTitle()), + () -> assertThat(applyFormResponse.startDate()).isEqualTo(applyForm.getStartDate()), + () -> assertThat(applyFormResponse.endDate()).isEqualTo(applyForm.getEndDate()), + () -> { + QuestionResponse questionResponse = applyFormResponse.questionResponses().get(0); + assertThat(questionResponse.id()).isEqualTo(question1.getId()); + assertThat(questionResponse.content()).isEqualTo(question1.getContent()); + assertThat(questionResponse.orderIndex()).isEqualTo(question1.getSequence()); + assertThat(questionResponse.required()).isEqualTo(question1.isRequired()); + assertThat(questionResponse.choiceResponses()).isEmpty(); + } + ); + } +} diff --git a/backend/src/test/java/com/cruru/applyform/service/ApplyFormServiceTest.java b/backend/src/test/java/com/cruru/applyform/service/ApplyFormServiceTest.java new file mode 100644 index 000000000..36457dbc6 --- /dev/null +++ b/backend/src/test/java/com/cruru/applyform/service/ApplyFormServiceTest.java @@ -0,0 +1,160 @@ +package com.cruru.applyform.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.cruru.applyform.controller.request.ApplyFormWriteRequest; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.applyform.exception.ApplyFormNotFoundException; +import com.cruru.applyform.exception.badrequest.StartDatePastException; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.LocalDateFixture; +import com.cruru.util.fixture.ProcessFixture; +import com.cruru.util.fixture.QuestionFixture; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("지원서 폼 서비스 테스트") +class ApplyFormServiceTest extends ServiceTest { + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private ApplyFormService applyFormService; + + private Dashboard dashboard; + + @BeforeEach + void setUp() { + dashboard = dashboardRepository.save(DashboardFixture.backend()); + } + + @DisplayName("지원공고를 성공적으로 생성한다.") + @Test + void create() { + // given + String title = "우아한테크코스 백엔드 7기 모집"; + String postingContent = "# 모집합니다! ## 사실 안모집합니다"; + LocalDateTime startDate = LocalDateFixture.oneDayLater(); + LocalDateTime endDate = LocalDateFixture.oneWeekLater(); + ApplyFormWriteRequest request = new ApplyFormWriteRequest(title, postingContent, startDate, endDate); + + // when + ApplyForm savedApplyForm = applyFormService.create(request, dashboard); + long applyFormId = savedApplyForm.getId(); + + // then + ApplyForm actualApplyForm = applyFormRepository.findById(applyFormId).get(); + assertAll( + () -> assertThat(actualApplyForm.getTitle()).isEqualTo(title), + () -> assertThat(actualApplyForm.getDescription()).isEqualTo(postingContent), + () -> assertThat(actualApplyForm.getStartDate()).isEqualTo(startDate), + () -> assertThat(actualApplyForm.getEndDate()).isEqualTo(endDate) + ); + } + + @DisplayName("지원 공고 생성 시 시작 날짜가 현재 날짜보다 이전일 경우 예외가 발생한다.") + @Test + void create_startDateInPast() { + // given + String title = "title"; + String description = "description"; + LocalDateTime startDate = LocalDateFixture.oneWeekAgo(); + LocalDateTime endDate = LocalDateFixture.oneWeekLater(); + ApplyFormWriteRequest request = new ApplyFormWriteRequest(title, description, startDate, endDate); + + // when&then + assertThatThrownBy(() -> applyFormService.create(request, dashboard)) + .isInstanceOf(StartDatePastException.class); + } + + @DisplayName("지원서 폼 질문 조회에 성공한다.") + @Test + void findById() { + // given + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + + // when + ApplyForm actualApplyForm = applyFormService.findById(applyForm.getId()); + + // then + assertAll( + () -> assertThat(actualApplyForm.getTitle()).isEqualTo(applyForm.getTitle()), + () -> assertThat(actualApplyForm.getStartDate()).isEqualTo(applyForm.getStartDate()), + () -> assertThat(actualApplyForm.getEndDate()).isEqualTo(applyForm.getEndDate()) + ); + } + + @DisplayName("지원서 폼 조회 시, 지원서 폼이 존재하지 않을 경우 예외가 발생한다.") + @Test + void findById_invalidApplyForm() { + // given + processRepository.save(ProcessFixture.applyType(dashboard)); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.frontend(dashboard)); + questionRepository.save(QuestionFixture.shortAnswerType(applyForm)); + + // when&then + assertThatThrownBy(() -> applyFormService.findById(-1L)).isInstanceOf(ApplyFormNotFoundException.class); + } + + @DisplayName("대시보드 ID로 지원폼을 조회한다.") + @Test + void findByDashboard() { + // given + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + + // when&then + assertDoesNotThrow(() -> applyFormService.findByDashboard(dashboard)); + assertThat(applyFormService.findByDashboard(dashboard)).isEqualTo(applyForm); + } + + @DisplayName("지원서 폼을 수정한다.") + @Test + void update() { + // given + String toChangeTitle = "크루루 백엔드 모집 공고~~"; + String toChangeDescription = "# 모집 공고 설명 #"; + LocalDateTime toChangeStartDate = LocalDateFixture.oneDayLater(); + LocalDateTime toChangeEndDate = LocalDateFixture.oneWeekLater(); + + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + ApplyFormWriteRequest request = new ApplyFormWriteRequest( + toChangeTitle, toChangeDescription, toChangeStartDate, toChangeEndDate + ); + + // when + applyFormService.update(applyForm, request); + + // then + ApplyForm actual = applyFormRepository.findById(applyForm.getId()).get(); + assertAll( + () -> assertThat(actual.getTitle()).isEqualTo(toChangeTitle), + () -> assertThat(actual.getDescription()).isEqualTo(toChangeDescription), + () -> assertThat(actual.getStartDate()).isEqualTo(toChangeStartDate), + () -> assertThat(actual.getEndDate()).isEqualTo(toChangeEndDate) + ); + } +} diff --git a/backend/src/test/java/com/cruru/auth/controller/AuthControllerTest.java b/backend/src/test/java/com/cruru/auth/controller/AuthControllerTest.java new file mode 100644 index 000000000..2466413fd --- /dev/null +++ b/backend/src/test/java/com/cruru/auth/controller/AuthControllerTest.java @@ -0,0 +1,134 @@ +package com.cruru.auth.controller; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +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.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.auth.controller.request.LoginRequest; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.util.ControllerTest; +import com.cruru.util.fixture.ClubFixture; +import com.cruru.util.fixture.MemberFixture; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("인증 컨트롤러 테스트") +class AuthControllerTest extends ControllerTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ClubRepository clubRepository; + + private Member member; + + @BeforeEach + void setup() { + clubRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + member = memberRepository.save(MemberFixture.ADMIN); + clubRepository.save(ClubFixture.create(member)); + } + + @DisplayName("로그인 성공 시 200을 응답한다.") + @Test + void login() { + // given + LoginRequest request = new LoginRequest(member.getEmail(), "qwer1234"); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("auth/login", + requestFields( + fieldWithPath("email").description("사용자 이메일"), + fieldWithPath("password").description("사용자 패스워드") + ), + responseFields(fieldWithPath("clubId").description("동아리의 id")), + responseHeaders(headerWithName("Set-Cookie").description("인증 쿠키 설정")), + responseCookies(cookieWithName("token").description("사용자 토큰")) + )) + .when().post("/v1/auth/login") + .then().log().all().statusCode(200); + } + + @DisplayName("잘못된 password로 로그인 시도 시 401을 응답한다.") + @Test + void login_unverifiedPassword() { + // given + LoginRequest request = new LoginRequest(member.getEmail(), "wrongPassword"); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("auth/login-fail/invalid-password", + requestFields( + fieldWithPath("email").description("사용자 이메일"), + fieldWithPath("password").description("잘못된 패스워드") + ) + )) + .when().post("/v1/auth/login") + .then().log().all().statusCode(401); + } + + @DisplayName("존재하지 않는 이메일로 로그인 시도 시 404를 응답한다.") + @Test + void login_emailNotFound() { + // given + LoginRequest request = new LoginRequest("invalid@email.com", member.getPassword()); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("auth/login-fail/email-not-found", + requestFields( + fieldWithPath("email").description("존재하지 않는 이메일"), + fieldWithPath("password").description("사용자 패스워드") + ) + )) + .when().post("/v1/auth/login") + .then().log().all().statusCode(404); + } + + @DisplayName("로그아웃을 성공하면 204를 반환한다.") + @Test + void logout() { + // given&when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .filter(document("auth/logout", + requestCookies(cookieWithName("token").description("사용자 토큰")), + responseHeaders(headerWithName("Set-Cookie").description("인증 해제 쿠키 설정")) + )) + .when().post("/v1/auth/logout") + .then().log().all().statusCode(204); + } + + @DisplayName("토큰이 없는 사용자가 로그아웃을 시도할 경우 401을 반환한다.") + @Test + void logout_withNoToken() { + // given&when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .filter(document("auth/logout-fail/token-not-found")) + .when().post("/v1/auth/logout") + .then().log().all().statusCode(401); + } +} diff --git a/backend/src/test/java/com/cruru/auth/security/JwtTokenProviderTest.java b/backend/src/test/java/com/cruru/auth/security/JwtTokenProviderTest.java new file mode 100644 index 000000000..773965b17 --- /dev/null +++ b/backend/src/test/java/com/cruru/auth/security/JwtTokenProviderTest.java @@ -0,0 +1,114 @@ +package com.cruru.auth.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.auth.security.jwt.JwtTokenProvider; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; + +@DisplayName("JWT Token Provider 테스트") +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +@ActiveProfiles("test") +class JwtTokenProviderTest { + + private static final String TEST_SECRET_KEY = "test"; + private static final String EMAIL_CLAIM = "email"; + private static final String ROLE_CLAIM = "role"; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + private Map claims; + + @BeforeEach + void setUp() { + claims = new HashMap<>(); + claims.put(EMAIL_CLAIM, "email@example.com"); + claims.put(ROLE_CLAIM, "ADMIN"); + } + + @DisplayName("토큰이 정상적으로 생성되는지 확인한다") + @Test + void create() { + // given&when + String token = jwtTokenProvider.createToken(claims); + Claims extractedClaims = Jwts.parser() + .setSigningKey(TEST_SECRET_KEY.getBytes()) + .parseClaimsJws(token) + .getBody(); + + // then + String expectedRole = (String) claims.get(ROLE_CLAIM); + String expectedEmail = (String) claims.get(EMAIL_CLAIM); + + assertAll( + () -> assertThat(extractedClaims).containsEntry(ROLE_CLAIM, expectedRole), + () -> assertThat(extractedClaims).containsEntry(EMAIL_CLAIM, expectedEmail), + () -> assertThat(extractedClaims.getExpiration()).isNotNull() + ); + } + + @DisplayName("유효한 토큰에서 이메일과 역할을 추출할 수 있는지 확인한다") + @Test + void extractEmailAndRole() { + // given + String token = jwtTokenProvider.createToken(claims); + + // when + String email = jwtTokenProvider.extractClaim(token, EMAIL_CLAIM); + String role = jwtTokenProvider.extractClaim(token, ROLE_CLAIM); + + // then + String expectedRole = (String) claims.get(ROLE_CLAIM); + String expectedEmail = (String) claims.get(EMAIL_CLAIM); + + assertAll( + () -> assertThat(email).isEqualTo(expectedEmail), + () -> assertThat(role).isEqualTo(expectedRole) + ); + } + + @DisplayName("만료된 토큰을 검증한다.") + @Test + void isAlive() { + // given + String expiredToken = generateExpiredToken(); + + // when&then + assertThat(jwtTokenProvider.isAlive(expiredToken)).isFalse(); + } + + private String generateExpiredToken() { + Date now = new Date(); + Date validity = new Date(now.getTime() - 3600000); // 1시간 전 만료 + + return Jwts.builder() + .addClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(SignatureAlgorithm.HS256, TEST_SECRET_KEY.getBytes()) + .compact(); + } + + @DisplayName("만료되지 않은 토큰을 검증한다.") + @Test + void isAlive_notValid() { + // given + String notExpiredToken = jwtTokenProvider.createToken(claims); + + // when&then + assertThat(jwtTokenProvider.isAlive(notExpiredToken)).isTrue(); + } +} diff --git a/backend/src/test/java/com/cruru/club/controller/ClubControllerTest.java b/backend/src/test/java/com/cruru/club/controller/ClubControllerTest.java new file mode 100644 index 000000000..036e5b614 --- /dev/null +++ b/backend/src/test/java/com/cruru/club/controller/ClubControllerTest.java @@ -0,0 +1,96 @@ +package com.cruru.club.controller; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.club.controller.request.ClubCreateRequest; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.util.ControllerTest; +import com.cruru.util.fixture.MemberFixture; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("동아리 컨트롤러 테스트") +class ClubControllerTest extends ControllerTest { + + @Autowired + private MemberRepository memberRepository; + + @DisplayName("동아리 생성 성공 시, 201을 응답한다.") + @Test + void create() { + // given + Member member = memberRepository.save(MemberFixture.DOBBY); + String name = "연합 동아리"; + ClubCreateRequest request = new ClubCreateRequest(name); + String url = String.format("/v1/clubs?memberId=%d", member.getId()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document("club/create/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("memberId").description("동아리를 생성할 사용자의 id")), + requestFields(fieldWithPath("name").description("생성할 동아리의 이름")) + )) + .when().post(url) + .then().log().all().statusCode(201); + } + + @DisplayName("멤버가 존재하지 않을 경우, 404을 응답한다.") + @Test + void create_memberNotFound() { + // given + String name = "연합 동아리"; + ClubCreateRequest request = new ClubCreateRequest(name); + long invalidMemberId = -1; + String url = String.format("/v1/clubs?memberId=%d", invalidMemberId); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document("club/create-fail/member-not-found/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("memberId").description("존재하지 않는 사용자의 id")), + requestFields(fieldWithPath("name").description("생성할 동아리의 이름")) + )) + .when().post(url) + .then().log().all().statusCode(404); + } + + @DisplayName("동아리 생성 시 조건에 맞지 않는 이름을 입력한 경우, 400을 응답한다.") + @Test + void create_invalidName() { + // given + String name = ""; + Member member = memberRepository.save(MemberFixture.DOBBY); + ClubCreateRequest request = new ClubCreateRequest(name); + String url = String.format("/v1/clubs?memberId=%d", member.getId()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(request) + .filter(document("club/create-fail/invalid-name/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("memberId").description("동아리를 생성할 사용자의 id")), + requestFields(fieldWithPath("name").description("조건에 맞지 않는 동아리의 이름")) + )) + .when().post(url) + .then().log().all().statusCode(400); + } +} diff --git a/backend/src/test/java/com/cruru/club/domain/ClubTest.java b/backend/src/test/java/com/cruru/club/domain/ClubTest.java new file mode 100644 index 000000000..b40ddcece --- /dev/null +++ b/backend/src/test/java/com/cruru/club/domain/ClubTest.java @@ -0,0 +1,63 @@ +package com.cruru.club.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.cruru.club.exception.badrequest.ClubNameBlankException; +import com.cruru.club.exception.badrequest.ClubNameCharacterException; +import com.cruru.club.exception.badrequest.ClubNameLengthException; +import com.cruru.member.domain.Member; +import com.cruru.util.fixture.MemberFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("동아리 도메인 테스트") +class ClubTest { + + @DisplayName("동아리 이름은 특수문자와 숫자를 허용한다.") + @ValueSource(strings = {"club!!", "(club!@#$%^&*)", "CLUB123"}) + @ParameterizedTest + void validClubName(String name) { + // given + Member member = MemberFixture.DOBBY; + + // when&then + assertThatCode(() -> new Club(name, member)).doesNotThrowAnyException(); + } + + @DisplayName("동아리 이름이 비어있으면 예외가 발생한다.") + @ValueSource(strings = {"", " "}) + @ParameterizedTest + void clubNameBlank(String name) { + // given + Member member = MemberFixture.DOBBY; + + // when&then + assertThatThrownBy(() -> new Club(name, member)) + .isInstanceOf(ClubNameBlankException.class); + } + + @DisplayName("동아리 이름이 32자 초과시 예외가 발생한다.") + @Test + void invalidClubNameLength() { + // given + Member member = MemberFixture.DOBBY; + + // when&then + assertThatThrownBy(() -> new Club("ThisStringLengthIs33!!!!!!!!!!!!!", member)) + .isInstanceOf(ClubNameLengthException.class); + } + + @DisplayName("동아리 이름에 허용되지 않은 글자가 들어가면 예외가 발생한다.") + @ValueSource(strings = {"invalidCharacter|", "invalidCharacter\\"}) + @ParameterizedTest + void invalidClubNameCharacter(String name) { + // given + Member member = MemberFixture.DOBBY; + + // when&then + assertThatThrownBy(() -> new Club(name, member)).isInstanceOf(ClubNameCharacterException.class); + } +} diff --git a/backend/src/test/java/com/cruru/club/domain/repository/ClubRepositoryTest.java b/backend/src/test/java/com/cruru/club/domain/repository/ClubRepositoryTest.java new file mode 100644 index 000000000..432053f03 --- /dev/null +++ b/backend/src/test/java/com/cruru/club/domain/repository/ClubRepositoryTest.java @@ -0,0 +1,54 @@ +package com.cruru.club.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.cruru.club.domain.Club; +import com.cruru.util.RepositoryTest; +import com.cruru.util.fixture.ClubFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("동아리 레포지토리 테스트") +class ClubRepositoryTest extends RepositoryTest { + + @Autowired + private ClubRepository clubRepository; + + @BeforeEach + void setUp() { + clubRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어 있는 ID를 가진 동아리를 저장하면, 해당 ID의 동아리는 후에 작성된 정보로 업데이트한다.") + @Test + void sameIdUpdate() { + //given + Club club = ClubFixture.create(); + Club saved = clubRepository.save(club); + + //when + Club updateClub = new Club(saved.getId(), "크루루", null); + clubRepository.save(updateClub); + + //then + Club findClub = clubRepository.findById(saved.getId()).get(); + assertThat(findClub.getName()).isEqualTo("크루루"); + } + + @DisplayName("ID가 없는 동아리를 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Club club1 = ClubFixture.create(); + Club club2 = ClubFixture.create(); + + //when + Club savedClub1 = clubRepository.save(club1); + Club savedClub2 = clubRepository.save(club2); + + //then + assertThat(savedClub1.getId() + 1).isEqualTo(savedClub2.getId()); + } +} diff --git a/backend/src/test/java/com/cruru/club/facade/ClubFacadeTest.java b/backend/src/test/java/com/cruru/club/facade/ClubFacadeTest.java new file mode 100644 index 000000000..986580a86 --- /dev/null +++ b/backend/src/test/java/com/cruru/club/facade/ClubFacadeTest.java @@ -0,0 +1,59 @@ +package com.cruru.club.facade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.club.controller.request.ClubCreateRequest; +import com.cruru.club.domain.Club; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.MemberFixture; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("동아리 파사드 서비스 테스트") +class ClubFacadeTest extends ServiceTest { + + @Autowired + private ClubFacade clubFacade; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private EntityManager entityManager; + + @DisplayName("회원가입 되어있는 회원이 새로운 동아리를 생성한다.") + @Test + void create() { + // given + Member member = memberRepository.save(MemberFixture.DOBBY); + ClubCreateRequest request = new ClubCreateRequest("연합동아리"); + + // when + long clubId = clubFacade.create(request, member.getId()); + + // then + Club actual = entityManager.createQuery( + "SELECT c FROM Club c JOIN FETCH c.member WHERE c.id = :id", Club.class) + .setParameter("id", clubId) + .getSingleResult(); + assertAll( + () -> assertThat(actual.getMember()).isEqualTo(member), + () -> assertThat(actual.getName()).isEqualTo(request.name()) + ); + } + + @DisplayName("회원의 email로 동아리를 조회한다.") + @Test + void findByMemberEmail() { + // when + long actualClubId = clubFacade.findByMemberEmail(defaultMember.getEmail()); + + // then + assertThat(defaultClub.getId()).isEqualTo(actualClubId); + } +} diff --git a/backend/src/test/java/com/cruru/club/service/ClubServiceTest.java b/backend/src/test/java/com/cruru/club/service/ClubServiceTest.java new file mode 100644 index 000000000..bf90c111b --- /dev/null +++ b/backend/src/test/java/com/cruru/club/service/ClubServiceTest.java @@ -0,0 +1,83 @@ +package com.cruru.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.cruru.club.controller.request.ClubCreateRequest; +import com.cruru.club.domain.Club; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ClubFixture; +import com.cruru.util.fixture.MemberFixture; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("동아리 서비스 테스트") +class ClubServiceTest extends ServiceTest { + + @Autowired + private ClubService clubService; + + @Autowired + private ClubRepository clubRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private EntityManager entityManager; + + @DisplayName("새로운 동아리를 생성한다.") + @Test + void create() { + // given + Member member = memberRepository.save(MemberFixture.DOBBY); + ClubCreateRequest request = new ClubCreateRequest("연합동아리"); + + // when + Club saved = clubService.create(request, member); + + // then + Club actual = entityManager.createQuery( + "SELECT c FROM Club c JOIN FETCH c.member WHERE c.id = :id", Club.class) + .setParameter("id", saved.getId()) + .getSingleResult(); + assertAll( + () -> assertThat(actual.getMember()).isEqualTo(member), + () -> assertThat(actual.getName()).isEqualTo(request.name()) + ); + } + + @DisplayName("동아리를 ID로 조회한다.") + @Test + void findById() { + // given + Club savedClub = clubRepository.save(ClubFixture.create()); + Club actual = clubService.findById(savedClub.getId()); + + // when&then + assertAll( + () -> assertDoesNotThrow(() -> clubService.findById(savedClub.getId())), + () -> assertThat(actual.getName()).isEqualTo(savedClub.getName()) + ); + } + + @DisplayName("회원으로 동아리를 조회한다.") + @Test + void findByMember() { + // given + // when + Club actual = clubService.findByMember(defaultMember); + + // then + assertAll( + () -> assertDoesNotThrow(() -> clubService.findByMember(defaultMember)), + () -> assertThat(actual.getName()).isEqualTo(defaultClub.getName()) + ); + } +} diff --git a/backend/src/test/java/com/cruru/config/DataSourceRouterTest.java b/backend/src/test/java/com/cruru/config/DataSourceRouterTest.java new file mode 100644 index 000000000..9e3b6d631 --- /dev/null +++ b/backend/src/test/java/com/cruru/config/DataSourceRouterTest.java @@ -0,0 +1,37 @@ +package com.cruru.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@DisplayName("Datasource Routing 테스트") +class DataSourceRouterTest { + + private final DataSourceRouter dataSourceRouter = new DataSourceRouter(); + + @Test + void determineCurrentLookupKeyForReadOnlyTransaction() { + // given + TransactionSynchronizationManager.setCurrentTransactionReadOnly(true); + + // when + Object lookupKey = dataSourceRouter.determineCurrentLookupKey(); + + // then + assertThat(lookupKey).isEqualTo(DataSourceRouter.READ_DATASOURCE_KEY); + } + + @Test + void determineCurrentLookupKeyForReadWriteTransaction() { + // give + TransactionSynchronizationManager.setCurrentTransactionReadOnly(false); + + // when + Object lookupKey = dataSourceRouter.determineCurrentLookupKey(); + + // then + assertThat(lookupKey).isEqualTo(DataSourceRouter.WRITE_DATASOURCE_KEY); + } +} diff --git a/backend/src/test/java/com/cruru/dashboard/controller/DashboardControllerTest.java b/backend/src/test/java/com/cruru/dashboard/controller/DashboardControllerTest.java new file mode 100644 index 000000000..4bbc0288c --- /dev/null +++ b/backend/src/test/java/com/cruru/dashboard/controller/DashboardControllerTest.java @@ -0,0 +1,252 @@ +package com.cruru.dashboard.controller; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +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.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.club.domain.Club; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.dashboard.controller.request.DashboardCreateRequest; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.question.controller.request.ChoiceCreateRequest; +import com.cruru.question.controller.request.QuestionCreateRequest; +import com.cruru.util.ControllerTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.ClubFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.LocalDateFixture; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.restdocs.payload.FieldDescriptor; + +@DisplayName("대시보드 컨트롤러 테스트") +class DashboardControllerTest extends ControllerTest { + + private static final FieldDescriptor[] DASHBOARD_PREVIEW_FIELD_DESCRIPTORS = { + fieldWithPath("dashboardId").description("대시보드의 id"), + fieldWithPath("applyFormId").description("지원폼의 id"), + fieldWithPath("title").description("지원폼의 공고명"), + fieldWithPath("stats.accept").description("합격 인원"), + fieldWithPath("stats.fail").description("불합격 인원"), + fieldWithPath("stats.inProgress").description("진행중 인원"), + fieldWithPath("stats.total").description("인원 총계"), + fieldWithPath("startDate").description("시작날짜"), + fieldWithPath("endDate").description("마감날짜") + }; + + private static final FieldDescriptor[] QUESTION_FIELD_DESCRIPTORS = { + fieldWithPath("type").description("질문 유형"), + fieldWithPath("question").description("질문 내용"), + fieldWithPath("choices").description("질문의 선택지들"), + fieldWithPath("orderIndex").description("질문의 순서"), + fieldWithPath("required").description("질문의 필수여부") + }; + + private static final FieldDescriptor[] CHOICE_FIELD_DESCRIPTORS = { + fieldWithPath("choice").description("객관식 질문의 선택지"), + fieldWithPath("orderIndex").description("선택지의 순서") + }; + + @Autowired + private ClubRepository clubRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + private Club club; + + private static Stream InvalidQuestionCreateRequest() { + String validChoice = "선택지1"; + int validOrderIndex = 0; + List validChoiceCreateRequests + = List.of(new ChoiceCreateRequest(validChoice, validOrderIndex)); + String validType = "DROPDOWN"; + String validQuestion = "객관식질문"; + boolean validRequired = false; + return Stream.of( + new QuestionCreateRequest(null, validQuestion, + validChoiceCreateRequests, validOrderIndex, validRequired), + new QuestionCreateRequest("", validQuestion, + validChoiceCreateRequests, validOrderIndex, validRequired), + new QuestionCreateRequest(validType, null, + validChoiceCreateRequests, validOrderIndex, validRequired), + new QuestionCreateRequest(validType, "", + validChoiceCreateRequests, validOrderIndex, validRequired), + new QuestionCreateRequest(validType, validQuestion, + List.of(new ChoiceCreateRequest(null, validOrderIndex)), validOrderIndex, validRequired), + new QuestionCreateRequest(validType, validQuestion, + List.of(new ChoiceCreateRequest("", validOrderIndex)), validOrderIndex, validRequired), + new QuestionCreateRequest(validType, validQuestion, + List.of(new ChoiceCreateRequest(validChoice, null)), validOrderIndex, validRequired), + new QuestionCreateRequest(validType, validQuestion, + List.of(new ChoiceCreateRequest(validChoice, -1)), validOrderIndex, validRequired), + new QuestionCreateRequest(validType, validQuestion, + validChoiceCreateRequests, null, validRequired), + new QuestionCreateRequest(validType, validQuestion, + validChoiceCreateRequests, -1, validRequired), + new QuestionCreateRequest(validType, validQuestion, + validChoiceCreateRequests, validOrderIndex, null) + ); + } + + @BeforeEach + void setUp() { + club = clubRepository.save(ClubFixture.create(defaultMember)); + } + + @DisplayName("대시보드 생성 성공 시, 201을 응답한다.") + @Test + void create() { + // given + List choiceCreateRequests = List.of(new ChoiceCreateRequest("선택지1", 1)); + List questionCreateRequests = List.of( + new QuestionCreateRequest("DROPDOWN", "객관식질문1", choiceCreateRequests, 1, false)); + DashboardCreateRequest request = new DashboardCreateRequest( + "크루루대시보드", + "# 공고 내용", + questionCreateRequests, + LocalDateFixture.oneDayLater(), + LocalDateFixture.oneWeekLater() + ); + String url = String.format("/v1/dashboards?clubId=%d", club.getId()); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .cookie("token", token) + .body(request) + .filter(document("dashboard/create", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("clubId").description("동아리의 id")), + requestFields( + fieldWithPath("title").description("공고 제목"), + fieldWithPath("postingContent").description("공고 내용"), + fieldWithPath("questions").description("질문들"), + fieldWithPath("startDate").description("공고 시작 날짜"), + fieldWithPath("endDate").description("공고 마감 날짜") + ).andWithPrefix("questions[].", QUESTION_FIELD_DESCRIPTORS) + .andWithPrefix("questions[].choices[].", CHOICE_FIELD_DESCRIPTORS), + responseFields( + fieldWithPath("applyFormId").description("지원폼의 id"), + fieldWithPath("dashboardId").description("대시보드의 id") + ) + )) + .when().post(url) + .then().log().all().statusCode(201); + } + + @DisplayName("대시보드 생성 시, 질문 생성 요청이 잘못되면 400을 응답한다.") + @ParameterizedTest + @MethodSource("InvalidQuestionCreateRequest") + void create_invalidQuestionCreateRequest(QuestionCreateRequest invalidQuestionCreateRequest) { + // given + List questionCreateRequests = List.of(invalidQuestionCreateRequest); + DashboardCreateRequest request = new DashboardCreateRequest( + "크루루대시보드", + "# 공고 내용", + questionCreateRequests, + LocalDateFixture.oneDayLater(), + LocalDateFixture.oneWeekLater() + ); + String url = String.format("/v1/dashboards?clubId=%d", club.getId()); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .cookie("token", token) + .body(request) + .filter(document("dashboard/create-fail/invalid-question", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("clubId").description("동아리의 id")), + requestFields( + fieldWithPath("title").description("공고 제목"), + fieldWithPath("postingContent").description("공고 내용"), + fieldWithPath("questions").description("질문들"), + fieldWithPath("startDate").description("공고 시작 날짜"), + fieldWithPath("endDate").description("공고 마감 날짜") + ).andWithPrefix("questions[].", QUESTION_FIELD_DESCRIPTORS) + .andWithPrefix("questions[].choices[].", CHOICE_FIELD_DESCRIPTORS) + )) + .when().post(url) + .then().log().all().statusCode(400); + } + + @DisplayName("대시보드 생성 성공 시, 동아리가 존재하지 않으면 404를 응답한다.") + @Test + void create_invalidClub() { + // given + List choiceCreateRequests = List.of(new ChoiceCreateRequest("선택지1", 1)); + List questionCreateRequests = List.of( + new QuestionCreateRequest("DROPDOWN", "객관식질문1", choiceCreateRequests, 1, false)); + DashboardCreateRequest request = new DashboardCreateRequest( + "크루루대시보드", + "# 공고 내용", + questionCreateRequests, + LocalDateFixture.oneDayLater(), + LocalDateFixture.oneWeekLater() + ); + long invalidClubId = -1; + String url = String.format("/v1/dashboards?clubId=%d", invalidClubId); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .cookie("token", token) + .body(request) + .filter(document("dashboard/create-fail/club-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("clubId").description("존재하지 않는 동아리 id")), + requestFields( + fieldWithPath("title").description("공고 제목"), + fieldWithPath("postingContent").description("공고 내용"), + fieldWithPath("questions").description("질문들"), + fieldWithPath("startDate").description("공고 시작 날짜"), + fieldWithPath("endDate").description("공고 마감 날짜") + ).andWithPrefix("questions[].", QUESTION_FIELD_DESCRIPTORS) + .andWithPrefix("questions[].choices[].", CHOICE_FIELD_DESCRIPTORS) + )) + .when().post(url) + .then().log().all().statusCode(404); + } + + @DisplayName("다건의 대시보드 요약 정보 요청 성공 시, 200을 응답한다") + @Test + void readDashboards_success() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend(club)); + applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + String url = String.format("/v1/dashboards?clubId=%d", club.getId()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document("dashboard/read", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("clubId").description("동아리의 id")), + responseFields( + fieldWithPath("clubName").description("동아리명"), + fieldWithPath("dashboards").description("대시보드들의 요약 정보") + ).andWithPrefix("dashboards[].", DASHBOARD_PREVIEW_FIELD_DESCRIPTORS) + )) + .when().get(url) + .then().log().all().statusCode(200); + } +} diff --git a/backend/src/test/java/com/cruru/dashboard/domain/repository/DashboardRepositoryTest.java b/backend/src/test/java/com/cruru/dashboard/domain/repository/DashboardRepositoryTest.java new file mode 100644 index 000000000..b85c26374 --- /dev/null +++ b/backend/src/test/java/com/cruru/dashboard/domain/repository/DashboardRepositoryTest.java @@ -0,0 +1,38 @@ +package com.cruru.dashboard.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.util.RepositoryTest; +import com.cruru.util.fixture.DashboardFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("대시보드 레포지토리 테스트") +class DashboardRepositoryTest extends RepositoryTest { + + @Autowired + private DashboardRepository dashboardRepository; + + @BeforeEach + void setUp() { + dashboardRepository.deleteAllInBatch(); + } + + @DisplayName("ID가 없는 대시보드를 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Dashboard dashboard1 = DashboardFixture.backend(); + Dashboard dashboard2 = DashboardFixture.frontend(); + + //when + Dashboard savedDashboard1 = dashboardRepository.save(dashboard1); + Dashboard savedDashboard2 = dashboardRepository.save(dashboard2); + + //then + assertThat(savedDashboard1.getId() + 1).isEqualTo(savedDashboard2.getId()); + } +} diff --git a/backend/src/test/java/com/cruru/dashboard/facade/DashboardFacadeTest.java b/backend/src/test/java/com/cruru/dashboard/facade/DashboardFacadeTest.java new file mode 100644 index 000000000..80176537b --- /dev/null +++ b/backend/src/test/java/com/cruru/dashboard/facade/DashboardFacadeTest.java @@ -0,0 +1,143 @@ +package com.cruru.dashboard.facade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.club.domain.Club; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.dashboard.controller.request.DashboardCreateRequest; +import com.cruru.dashboard.controller.response.DashboardCreateResponse; +import com.cruru.dashboard.controller.response.DashboardPreviewResponse; +import com.cruru.dashboard.controller.response.DashboardsOfClubResponse; +import com.cruru.dashboard.controller.response.StatsResponse; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.question.controller.request.ChoiceCreateRequest; +import com.cruru.question.controller.request.QuestionCreateRequest; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.ClubFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.LocalDateFixture; +import com.cruru.util.fixture.ProcessFixture; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("대시보드 파사드 서비스 테스트") +class DashboardFacadeTest extends ServiceTest { + + @Autowired + private DashboardFacade dashboardFacade; + + @Autowired + private ClubRepository clubRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + private Club club; + + @BeforeEach + void setUp() { + club = clubRepository.save(ClubFixture.create(defaultMember)); + } + + @DisplayName("대시보드(공고)를 생성한다.") + @Test + void create() { + // given + List choiceCreateRequests = List.of(new ChoiceCreateRequest("선택지1", 1)); + List questionCreateRequests = List.of( + new QuestionCreateRequest("DROPDOWN", "객관식질문1", choiceCreateRequests, 1, false)); + String title = "크루루대시보드"; + String postingContent = "# 공고 내용"; + LocalDateTime startDate = LocalDateFixture.oneDayLater(); + LocalDateTime endDate = LocalDateFixture.oneWeekLater(); + DashboardCreateRequest request = new DashboardCreateRequest( + title, + postingContent, + questionCreateRequests, + startDate, + endDate + ); + + // when + DashboardCreateResponse response = dashboardFacade.create(club.getId(), request); + + // then + assertThat(dashboardRepository.findById(response.dashboardId())).isPresent(); + } + + @DisplayName("다건의 대시보드 정보를 조회한다.") + @Test + void findAllDashboardsByClubId() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend(club)); + Dashboard dashboard1 = dashboardRepository.save(DashboardFixture.backend(club)); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + ApplyForm applyForm1 = applyFormRepository.save(ApplyFormFixture.backend(dashboard1)); + Process firstProcess = processRepository.save(ProcessFixture.applyType(dashboard)); + Process lastProcess = processRepository.save(ProcessFixture.approveType(dashboard)); + Process firstProcess1 = processRepository.save(ProcessFixture.applyType(dashboard1)); + Process lastProcess1 = processRepository.save(ProcessFixture.approveType(dashboard1)); + + List applicants = List.of( + // 마지막 프로세스에 있으면서 불합격 상태인 경우, 불합격 + ApplicantFixture.rejectedRush(lastProcess), + ApplicantFixture.rejectedRush(firstProcess), + ApplicantFixture.pendingDobby(lastProcess), + ApplicantFixture.pendingDobby(firstProcess), + ApplicantFixture.pendingDobby(firstProcess), + ApplicantFixture.pendingDobby(firstProcess) + ); + applicantRepository.saveAll(applicants); + + List applicants1 = List.of( + ApplicantFixture.rejectedRush(lastProcess1), + ApplicantFixture.rejectedRush(firstProcess1), + ApplicantFixture.pendingDobby(lastProcess1), + ApplicantFixture.pendingDobby(firstProcess1), + ApplicantFixture.pendingDobby(firstProcess1), + ApplicantFixture.pendingDobby(firstProcess1) + ); + applicantRepository.saveAll(applicants1); + + // when + DashboardsOfClubResponse dashboardsOfClubResponse = + dashboardFacade.findAllDashboardsByClubId(club.getId()); + + // then + DashboardPreviewResponse dashboardPreview = dashboardsOfClubResponse.dashboardPreviewResponses().get(0); + StatsResponse stats = dashboardPreview.stats(); + assertAll( + () -> assertThat(dashboardsOfClubResponse.clubName()).isEqualTo(club.getName()), + () -> assertThat(dashboardPreview.dashboardId()).isEqualTo(dashboard.getId()), + () -> assertThat(dashboardPreview.title()).isEqualTo(applyForm.getTitle()), + () -> assertThat(dashboardPreview.applyFormId()).isEqualTo(applyForm.getId()), + () -> assertThat(dashboardPreview.endDate()).isEqualTo(applyForm.getEndDate()), + () -> assertThat(stats.accept()).isEqualTo(1), + () -> assertThat(stats.fail()).isEqualTo(2), + () -> assertThat(stats.inProgress()).isEqualTo(3) + ); + } +} diff --git a/backend/src/test/java/com/cruru/dashboard/service/DashboardServiceTest.java b/backend/src/test/java/com/cruru/dashboard/service/DashboardServiceTest.java new file mode 100644 index 000000000..f80aa79bb --- /dev/null +++ b/backend/src/test/java/com/cruru/dashboard/service/DashboardServiceTest.java @@ -0,0 +1,94 @@ +package com.cruru.dashboard.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.club.domain.Club; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.DashboardApplyFormDto; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.ClubFixture; +import com.cruru.util.fixture.DashboardFixture; +import java.util.Comparator; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("대시보드 서비스 테스트") +class DashboardServiceTest extends ServiceTest { + + @Autowired + private DashboardService dashboardService; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ClubRepository clubRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @DisplayName("새로운 대시보드를 생성시, 기본 프로세스 2개가 생성된다.") + @Test + void create_createDefaultProcess() { + // given + Club club = ClubFixture.create(); + clubRepository.save(club); + + // when + Dashboard createdDashboard = dashboardService.create(club); + + // then + List processes = processRepository.findAllByDashboardId(createdDashboard.getId()) + .stream() + .sorted(Comparator.comparingInt(Process::getSequence)) + .toList(); + assertAll( + () -> assertThat(processes).hasSize(2), + () -> assertThat(processes.get(0).getSequence()).isZero(), + () -> assertThat(processes.get(1).getSequence()).isEqualTo(1) + ); + } + + @DisplayName("대시보드를 ID를 통해 조회한다.") + @Test + void findById() { + // given + Dashboard backendDashboard = dashboardRepository.save(DashboardFixture.backend()); + + // when&then + long id = backendDashboard.getId(); + assertDoesNotThrow(() -> dashboardService.findById(id)); + assertThat(dashboardService.findById(backendDashboard.getId())).isEqualTo(backendDashboard); + } + + @DisplayName("동아리 ID로 동아리가 가지고 있는 모든 대시보드 ID를 조회한다.") + @Test + void findAllByClub() { + // given + Club club = clubRepository.save(ClubFixture.create()); + Dashboard backendDashboard = dashboardRepository.save(DashboardFixture.backend(club)); + Dashboard frontendDashboard = dashboardRepository.save(DashboardFixture.frontend(club)); + ApplyForm backendApplyform = applyFormRepository.save(ApplyFormFixture.backend(backendDashboard)); + ApplyForm frontendApplyform = applyFormRepository.save(ApplyFormFixture.frontend(frontendDashboard)); + + // when&then + assertThat(dashboardService.findAllByClub(club.getId())).containsExactlyInAnyOrder( + new DashboardApplyFormDto(backendDashboard, backendApplyform), + new DashboardApplyFormDto(frontendDashboard, frontendApplyform) + ); + } +} diff --git a/backend/src/test/java/com/cruru/email/controller/EmailControllerTest.java b/backend/src/test/java/com/cruru/email/controller/EmailControllerTest.java new file mode 100644 index 000000000..1ce44975a --- /dev/null +++ b/backend/src/test/java/com/cruru/email/controller/EmailControllerTest.java @@ -0,0 +1,124 @@ +package com.cruru.email.controller; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.util.ControllerTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.EmailFixture; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.io.File; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("이메일 컨트롤러 테스트") +class EmailControllerTest extends ControllerTest { + + @Autowired + private ApplicantRepository applicantRepository; + + @DisplayName("이메일 발송 성공 시, 200을 응답한다.") + @Test + void send() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby()); + String subject = EmailFixture.SUBJECT; + String content = EmailFixture.APPROVE_CONTENT; + File file = new File(getClass().getClassLoader().getResource("static/email_test.txt").getFile()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .multiPart("clubId", defaultClub.getId()) + .multiPart("applicantIds", applicant.getId()) + .multiPart("subject", subject) + .multiPart("content", content) + .multiPart("files", file) + .contentType(ContentType.MULTIPART) + .filter(document("email/send", + requestCookies(cookieWithName("token").description("사용자 토큰")), + requestParts( + partWithName("clubId").description("발송 동아리 id"), + partWithName("applicantIds").description("수신자 id 목록"), + partWithName("subject").description("이메일 제목"), + partWithName("content").description("이메일 본문"), + partWithName("files").description("이메일 첨부 파일") + ) + )) + .when().post("/v1/emails/send") + .then().log().all().statusCode(200); + } + + @DisplayName("존재하지 않는 지원자 id를 수신자로 설정한 경우, 404를 응답한다.") + @Test + void send_invalidRequest() { + // given + long invalidId = -1; + String subject = EmailFixture.SUBJECT; + String content = EmailFixture.APPROVE_CONTENT; + File file = new File(getClass().getClassLoader().getResource("static/email_test.txt").getFile()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .multiPart("clubId", defaultClub.getId()) + .multiPart("applicantIds", invalidId) + .multiPart("subject", subject) + .multiPart("content", content) + .multiPart("files", file) + .contentType(ContentType.MULTIPART) + .filter(document("email/send-fail/invalid-email", + requestCookies(cookieWithName("token").description("사용자 토큰")), + requestParts( + partWithName("clubId").description("발송 동아리 id"), + partWithName("applicantIds").description("적절하지 않은 수신자 id가 포함된 목록"), + partWithName("subject").description("이메일 제목"), + partWithName("content").description("이메일 본문"), + partWithName("files").description("이메일 첨부 파일") + ) + )) + .when().post("/v1/emails/send") + .then().log().all().statusCode(404); + } + + @DisplayName("존재하지 않는 동아리 id를 발송자로 설정한 경우, 404를 응답한다.") + @Test + void send_clubNotExist() { + // given + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby()); + long invalidId = -1; + long email = applicant.getId(); + String subject = EmailFixture.SUBJECT; + String content = EmailFixture.APPROVE_CONTENT; + File file = new File(getClass().getClassLoader().getResource("static/email_test.txt").getFile()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .multiPart("clubId", invalidId) + .multiPart("applicantIds", email) + .multiPart("subject", subject) + .multiPart("content", content) + .multiPart("files", file) + .contentType(ContentType.MULTIPART) + .filter(document("email/send-fail/club-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + requestParts( + partWithName("clubId").description("존재하지 않는 발송 동아리 id"), + partWithName("applicantIds").description("수신자 id 목록"), + partWithName("subject").description("이메일 제목"), + partWithName("content").description("이메일 본문"), + partWithName("files").description("이메일 첨부 파일") + ) + )) + .when().post("/v1/emails/send") + .then().log().all().statusCode(404); + } +} diff --git a/backend/src/test/java/com/cruru/email/domain/EmailTest.java b/backend/src/test/java/com/cruru/email/domain/EmailTest.java new file mode 100644 index 000000000..c7de2853e --- /dev/null +++ b/backend/src/test/java/com/cruru/email/domain/EmailTest.java @@ -0,0 +1,50 @@ +package com.cruru.email.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.cruru.email.exception.EmailContentLengthException; +import com.cruru.email.exception.EmailSubjectLengthException; +import com.cruru.util.fixture.EmailFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("이메일 발송 내역 도메인 테스트") +class EmailTest { + + @DisplayName("이메일 제목이 998자를 초과하면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"012", "가나다", "abc"}) + void invalidEmailSubjectLength(String subject) { + // given + int repeatCount = 333; + StringBuilder stringBuilder = new StringBuilder(subject.length() * repeatCount); + String thisSubjectLengthIs999 = subject.repeat(repeatCount); + stringBuilder.append(thisSubjectLengthIs999); + + String invalidSubject = stringBuilder.toString(); + String content = EmailFixture.APPROVE_CONTENT; + + // when&then + assertThatThrownBy(() -> new Email(null, null, invalidSubject, content, true)) + .isInstanceOf(EmailSubjectLengthException.class); + } + + @DisplayName("이메일 본문이 10,000자를 초과하면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"0123456789", "가나다라마바사아자차", "abcdefghij"}) + void invalidEmailContentLength(String content) { + // given + int repeatCount = 1000; + StringBuilder stringBuilder = new StringBuilder(content.length() * repeatCount); + String thisContentLengthIs10000 = content.repeat(repeatCount); + stringBuilder.append(thisContentLengthIs10000); + + String subject = EmailFixture.SUBJECT; + String invalidContent = stringBuilder.append("!").toString(); + + // when&then + assertThatThrownBy(() -> new Email(null, null, subject, invalidContent, true)) + .isInstanceOf(EmailContentLengthException.class); + } +} diff --git a/backend/src/test/java/com/cruru/email/domain/repository/EmailRepositoryTest.java b/backend/src/test/java/com/cruru/email/domain/repository/EmailRepositoryTest.java new file mode 100644 index 000000000..fd39d6ca9 --- /dev/null +++ b/backend/src/test/java/com/cruru/email/domain/repository/EmailRepositoryTest.java @@ -0,0 +1,61 @@ +package com.cruru.email.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.cruru.email.domain.Email; +import com.cruru.util.RepositoryTest; +import com.cruru.util.fixture.EmailFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("발송 내역 레포지토리 테스트") +class EmailRepositoryTest extends RepositoryTest { + + @Autowired + private EmailRepository emailRepository; + + @BeforeEach + void setUp() { + emailRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어 있는 ID를 가진 이메일 발송 내역을 저장하면, 해당 ID의 이메일 발송 내역은 후에 작성된 정보로 업데이트한다.") + @Test + void sameIdUpdate() { + //given + Email email = EmailFixture.approveEmail(); + Email saved = emailRepository.save(email); + + //when + Email updatedEmail = new Email( + email.getId(), + null, + null, + EmailFixture.SUBJECT, + EmailFixture.REJECT_CONTENT, + true + ); + emailRepository.save(updatedEmail); + + //then + Email findEmail = emailRepository.findById(saved.getId()).get(); + assertThat(findEmail.getContent()).isEqualTo(EmailFixture.REJECT_CONTENT); + } + + @DisplayName("ID가 없는 이메일 발송 내역을 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Email email1 = EmailFixture.approveEmail(); + Email email2 = EmailFixture.rejectEmail(); + + //when + Email savedEmail1 = emailRepository.save(email1); + Email savedEmail2 = emailRepository.save(email2); + + //then + assertThat(savedEmail1.getId() + 1).isEqualTo(savedEmail2.getId()); + } +} diff --git a/backend/src/test/java/com/cruru/email/facade/EmailFacadeTest.java b/backend/src/test/java/com/cruru/email/facade/EmailFacadeTest.java new file mode 100644 index 000000000..6c451593c --- /dev/null +++ b/backend/src/test/java/com/cruru/email/facade/EmailFacadeTest.java @@ -0,0 +1,63 @@ +package com.cruru.email.facade; + +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.email.controller.dto.EmailRequest; +import com.cruru.email.domain.Email; +import com.cruru.email.service.EmailService; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.EmailFixture; +import jakarta.mail.internet.MimeMessage; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; + +@DisplayName("발송 내역 파사드 테스트") +class EmailFacadeTest extends ServiceTest { + + @SpyBean + EmailService emailService; + @Autowired + private ApplicantRepository applicantRepository; + @Autowired + private EmailFacade emailFacade; + + @DisplayName("이메일을 비동기로 발송하고, 발송 내역을 저장한다.") + @Test + void sendAndSave() { + // given + Mockito.doAnswer(invocation -> { + TimeUnit.SECONDS.sleep(1); + return null; + }).when(javaMailSender).send(any(MimeMessage.class)); + + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby()); + EmailRequest request = new EmailRequest( + defaultClub.getId(), + List.of(applicant.getId()), + EmailFixture.SUBJECT, + EmailFixture.APPROVE_CONTENT, + null + ); + + // when + emailFacade.send(request); + + // then + verify(javaMailSender, times(0)).send(any(MimeMessage.class)); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + verify(javaMailSender, times(1)).send(any(MimeMessage.class)); + verify(emailService, times(1)).save(any(Email.class)); + }); + } +} diff --git a/backend/src/test/java/com/cruru/email/service/EmailServiceTest.java b/backend/src/test/java/com/cruru/email/service/EmailServiceTest.java new file mode 100644 index 000000000..fdc355faf --- /dev/null +++ b/backend/src/test/java/com/cruru/email/service/EmailServiceTest.java @@ -0,0 +1,43 @@ +package com.cruru.email.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.email.domain.Email; +import com.cruru.email.domain.repository.EmailRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.EmailFixture; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("발송 내역 서비스 테스트") +class EmailServiceTest extends ServiceTest { + + @Autowired + private EmailRepository emailRepository; + + @Autowired + private EmailService emailService; + + @DisplayName("발송 내역을 저장한다.") + @Test + void save() { + // given + String subject = EmailFixture.SUBJECT; + String content = EmailFixture.APPROVE_CONTENT; + + // when + emailService.save(EmailFixture.approveEmail()); + + // then + List emails = emailRepository.findAll(); + Email actual = emails.get(0); + assertAll( + () -> assertThat(emails).hasSize(1), + () -> assertThat(actual.getSubject()).isEqualTo(subject), + () -> assertThat(actual.getContent()).isEqualTo(content) + ); + } +} diff --git a/backend/src/test/java/com/cruru/email/util/FileUtilTest.java b/backend/src/test/java/com/cruru/email/util/FileUtilTest.java new file mode 100644 index 000000000..6527ca30d --- /dev/null +++ b/backend/src/test/java/com/cruru/email/util/FileUtilTest.java @@ -0,0 +1,66 @@ +package com.cruru.email.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +class FileUtilTest { + + @DisplayName("임시 파일들을 임시 경로에 저장한다.") + @Test + void saveTempFiles() throws IOException { + // given + File file = new File(getClass().getClassLoader().getResource("static/email_test.txt").getFile()); + FileInputStream input = new FileInputStream(file); + MultipartFile multipartFile = new MockMultipartFile("file", + file.getName(), "text/plain", input); + + // when + List tempFiles = FileUtil.saveTempFiles(List.of(multipartFile)); + + // then + assertAll( + () -> assertThat(tempFiles).hasSize(1), + () -> assertThat(tempFiles.get(0)).isFile(), + () -> assertThat(tempFiles.get(0).getName()).endsWith("email_test.txt") + ); + } + + @DisplayName("인자로 들어온 파일 컬렉션이 null이거나 empty인 경우 빈 리스트를 반환한다.") + @NullAndEmptySource + @ParameterizedTest + void saveTempFiles_nullOrEmpty(List files) throws IOException { + // when + List tempFiles = FileUtil.saveTempFiles(files); + + // then + assertThat(tempFiles).isEmpty(); + } + + @DisplayName("파일을 삭제한다.") + @Test + void deleteFile() throws IOException { + // given + File file = new File(getClass().getClassLoader().getResource("static/email_test.txt").getFile()); + FileInputStream input = new FileInputStream(file); + MultipartFile multipartFile = new MockMultipartFile("file", + file.getName(), "text/plain", input); + List tempFiles = FileUtil.saveTempFiles(List.of(multipartFile)); + + // when + FileUtil.deleteFiles(tempFiles); + + // then + assertThat(tempFiles.get(0)).doesNotExist(); + } +} diff --git a/backend/src/test/java/com/cruru/member/controller/MemberControllerTest.java b/backend/src/test/java/com/cruru/member/controller/MemberControllerTest.java new file mode 100644 index 000000000..712190a00 --- /dev/null +++ b/backend/src/test/java/com/cruru/member/controller/MemberControllerTest.java @@ -0,0 +1,79 @@ +package com.cruru.member.controller; + +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.member.controller.request.MemberCreateRequest; +import com.cruru.util.ControllerTest; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("사용자 컨트롤러 테스트") +class MemberControllerTest extends ControllerTest { + + private static Stream InvalidMemberSignUpRequest() { + String validName = "크루루"; + String validMail = "mail@mail.com"; + String validPassword = "newPassword214!"; + String validPhone = "01012341234"; + return Stream.of( + new MemberCreateRequest(null, validMail, validPassword, validPhone), + new MemberCreateRequest("", validMail, validPassword, validPhone), + new MemberCreateRequest(validName, null, validPassword, validPhone), + new MemberCreateRequest(validName, "", validPassword, validPhone), + new MemberCreateRequest(validName, "notMail", validPassword, validPhone), + new MemberCreateRequest(validName, validMail, null, validPhone), + new MemberCreateRequest(validName, validMail, "", validPhone), + new MemberCreateRequest(validName, validMail, validPassword, null), + new MemberCreateRequest(validName, validMail, validPassword, "") + ); + } + + @DisplayName("사용자를 생성 성공 시 201을 응답한다.") + @Test + void create() { + // given + MemberCreateRequest request = new MemberCreateRequest("크루루", "mail@mail.com", "newPassword214!", "01012341234"); + + // when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("member/signup", + requestFields( + fieldWithPath("clubName").description("동아리명"), + fieldWithPath("email").description("사용자 이메일"), + fieldWithPath("password").description("사용자 패스워드"), + fieldWithPath("phone").description("사용자 전화번호") + ) + )) + .when().post("/v1/members/signup") + .then().log().all().statusCode(201); + } + + @DisplayName("유효하지 않은 요청으로 가입 시 400을 응답한다.") + @ParameterizedTest + @MethodSource("InvalidMemberSignUpRequest") + void create_invalidEmail(MemberCreateRequest request) { + // given&when&then + RestAssured.given(spec).log().all() + .contentType(ContentType.JSON) + .body(request) + .filter(document("member/signup-fail/invalid-request", + requestFields( + fieldWithPath("clubName").description("동아리명"), + fieldWithPath("email").description("사용자 이메일"), + fieldWithPath("password").description("사용자 패스워드"), + fieldWithPath("phone").description("사용자 전화번호") + ) + )) + .when().post("/v1/members/signup") + .then().log().all().statusCode(400); + } +} diff --git a/backend/src/test/java/com/cruru/member/domain/MemberTest.java b/backend/src/test/java/com/cruru/member/domain/MemberTest.java new file mode 100644 index 000000000..e5a92bd31 --- /dev/null +++ b/backend/src/test/java/com/cruru/member/domain/MemberTest.java @@ -0,0 +1,47 @@ +package com.cruru.member.domain; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.cruru.member.exception.badrequest.MemberIllegalPhoneNumberException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("회원 도메인 테스트") +class MemberTest { + + @DisplayName("허용된 값들로 Member를 생성한다.") + @ParameterizedTest + @CsvSource({ + "test1@example.com, Password123!, 01012345678", + "test2@example.com, Pass@1234, 0212345678", + "test3@example.com, MyPassword1!, 0311234567" + }) + void createMember(String email, String password, String phone) { + // given&when&then + assertDoesNotThrow(() -> new Member(email, password, phone)); + } + + @DisplayName("허용되지 않는 전화번호로 Member 생성 시 예외를 발생시킨다.") + @ParameterizedTest + @ValueSource(strings = { + "0123456789", // 잘못된 시작번호 + "010-1234-567", // 형식에 맞지 않음 + "031-1234-56789", // 형식에 맞지 않음 + "02123456789", // 형식에 맞지 않음 + "1", // 짧은 전화번호 + "phonenumber", // 번호가 없는 문자열 + "123451234512345", // 길이 초과 전화번호 + "a1012341234" // 문자가 포함된 전화번호 + }) + void createMemberWithInvalidPhoneNumber(String phone) { + // given + String email = "test@example.com"; + String password = "ValidPassword123!"; + + // when&then + assertThrows(MemberIllegalPhoneNumberException.class, () -> new Member(email, password, phone)); + } +} diff --git a/backend/src/test/java/com/cruru/member/domain/repository/MemberRepositoryTest.java b/backend/src/test/java/com/cruru/member/domain/repository/MemberRepositoryTest.java new file mode 100644 index 000000000..9ce94e8a6 --- /dev/null +++ b/backend/src/test/java/com/cruru/member/domain/repository/MemberRepositoryTest.java @@ -0,0 +1,72 @@ +package com.cruru.member.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.cruru.member.domain.Member; +import com.cruru.member.domain.MemberRole; +import com.cruru.util.RepositoryTest; +import com.cruru.util.fixture.MemberFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +@DisplayName("회원 레포지토리 테스트") +class MemberRepositoryTest extends RepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @BeforeEach + void setUp() { + memberRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어 있는 ID를 가진 사용자를 저장하면, 해당 ID의 사용자는 후에 작성된 정보로 업데이트한다.") + @Test + void sameIdUpdate() { + //given + Member member = MemberFixture.DOBBY; + Member saved = memberRepository.save(member); + + //when + Member updateMember = new Member(saved.getId(), "email", "newPassword214!", "01012341234", MemberRole.ADMIN); + memberRepository.save(updateMember); + + //then + Member findMember = memberRepository.findById(saved.getId()).get(); + assertThat(findMember.getPassword()).isEqualTo("newPassword214!"); + assertThat(findMember.getPhone()).isEqualTo("01012341234"); + } + + @DisplayName("ID가 없는 사용자를 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Member member1 = MemberFixture.DOBBY; + Member member2 = MemberFixture.RUSH; + + //when + Member savedMember1 = memberRepository.save(member1); + Member savedMember2 = memberRepository.save(member2); + + //then + assertThat(savedMember1.getId() + 1).isEqualTo(savedMember2.getId()); + } + + @DisplayName("같은 email을 가진 member를 저장 시, 예외가 발생한다.") + @Test + void save_duplicateEmail() { + //given + Member member1 = MemberFixture.DOBBY; + Member member2 = MemberFixture.DOBBY; + + //when + memberRepository.save(member1); + + //then + assertThatThrownBy(() -> memberRepository.save(member2)).isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/backend/src/test/java/com/cruru/member/facade/MemberFacadeTest.java b/backend/src/test/java/com/cruru/member/facade/MemberFacadeTest.java new file mode 100644 index 000000000..aa00e698d --- /dev/null +++ b/backend/src/test/java/com/cruru/member/facade/MemberFacadeTest.java @@ -0,0 +1,45 @@ +package com.cruru.member.facade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.member.controller.request.MemberCreateRequest; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.util.ServiceTest; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("회원 파사드 서비스 테스트") +class MemberFacadeTest extends ServiceTest { + + @Autowired + private MemberFacade memberFacade; + + @Autowired + private MemberRepository memberRepository; + + @DisplayName("사용자를 생성하면 ID를 반환한다.") + @Test + void create() { + // given + String clubName = "크루루"; + String email = "new@mail.com"; + String password = "newPassword214!"; + String phone = "01012341234"; + MemberCreateRequest request = new MemberCreateRequest(clubName, email, password, phone); + + // when + long memberId = memberFacade.create(request); + + // then + Optional member = memberRepository.findById(memberId); + assertAll( + () -> assertThat(member).isPresent(), + () -> assertThat(member.get().getEmail()).isEqualTo(email), + () -> assertThat(member.get().getPhone()).isEqualTo(phone) + ); + } +} diff --git a/backend/src/test/java/com/cruru/member/service/MemberServiceTest.java b/backend/src/test/java/com/cruru/member/service/MemberServiceTest.java new file mode 100644 index 000000000..c630622de --- /dev/null +++ b/backend/src/test/java/com/cruru/member/service/MemberServiceTest.java @@ -0,0 +1,120 @@ +package com.cruru.member.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.cruru.member.controller.request.MemberCreateRequest; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.member.exception.badrequest.MemberIllegalPasswordException; +import com.cruru.member.exception.badrequest.MemberPasswordLengthException; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.MemberFixture; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("사용자 서비스 테스트") +class MemberServiceTest extends ServiceTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @DisplayName("사용자를 생성하면 ID를 반환한다.") + @Test + void create() { + // given + String clubName = "크루루"; + String email = "new@mail.com"; + String password = "newPassword214!"; + String phone = "01012341234"; + MemberCreateRequest request = new MemberCreateRequest(clubName, email, password, phone); + + // when + Member member = memberService.create(request); + + // then + Optional actualMember = memberRepository.findById(member.getId()); + assertAll( + () -> assertThat(actualMember).isPresent(), + + () -> assertThat(actualMember.get().getEmail()).isEqualTo(email), + () -> assertThat(actualMember.get().getPhone()).isEqualTo(phone) + ); + } + + @DisplayName("회원을 ID로 조회한다.") + @Test + void findById() { + // given + Member savedMember = memberRepository.save(MemberFixture.DOBBY); + Member actualMember = memberService.findById(savedMember.getId()); + // when&then + assertAll( + () -> assertDoesNotThrow(() -> memberService.findById(savedMember.getId())), + + () -> assertThat(actualMember.getEmail()).isEqualTo(savedMember.getEmail()), + () -> assertThat(actualMember.getPhone()).isEqualTo(savedMember.getPhone()), + () -> assertThat(actualMember.getPassword()).isEqualTo(savedMember.getPassword()) + ); + } + + @DisplayName("허용되지 않는 비밀번호 길이로 Member 생성 시 예외를 발생시킨다.") + @ParameterizedTest + @ValueSource(strings = { + "short1!", // 길이가 8자 미만 + "VeryLongPassword12345678901234567890!" // 길이가 32자 초과 + }) + void createMemberWithLengthPassword(String password) { + // given + String email = "test@example.com"; + String phone = "01012345678"; + MemberCreateRequest memberCreateRequest = new MemberCreateRequest("크루루", email, password, phone); + + // when&then + assertThatThrownBy(() -> memberService.create(memberCreateRequest)) + .isInstanceOf(MemberPasswordLengthException.class); + } + + @DisplayName("허용되지 않는 비밀번호 형식으로 Member 생성 시 예외를 발생시킨다.") + @ParameterizedTest + @ValueSource(strings = { + "NoNumber!", // 숫자가 없음 + "NoSpecial123" // 특수문자가 없음 + }) + void createMemberWithInvalidPassword(String password) { + // given + String email = "test@example.com"; + String phone = "01012345678"; + MemberCreateRequest memberCreateRequest = new MemberCreateRequest("크루루", email, password, phone); + + // when&then + assertThatThrownBy(() -> memberService.create(memberCreateRequest)) + .isInstanceOf(MemberIllegalPasswordException.class); + } + + @DisplayName("회원을 email로 조회한다.") + @Test + void findByEmail() { + // given + Member savedMember = memberRepository.save(MemberFixture.DOBBY); + Member actualMember = memberService.findByEmail(savedMember.getEmail()); + // when&then + assertAll( + () -> assertDoesNotThrow(() -> memberService.findByEmail(savedMember.getEmail())), + + () -> assertThat(actualMember.getId()).isEqualTo(savedMember.getId()), + () -> assertThat(actualMember.getEmail()).isEqualTo(savedMember.getEmail()), + () -> assertThat(actualMember.getPhone()).isEqualTo(savedMember.getPhone()), + () -> assertThat(actualMember.getPassword()).isEqualTo(savedMember.getPassword()) + ); + } +} diff --git a/backend/src/test/java/com/cruru/process/controller/ProcessControllerTest.java b/backend/src/test/java/com/cruru/process/controller/ProcessControllerTest.java new file mode 100644 index 000000000..53dd0b21e --- /dev/null +++ b/backend/src/test/java/com/cruru/process/controller/ProcessControllerTest.java @@ -0,0 +1,381 @@ +package com.cruru.process.controller; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +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.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.controller.request.ProcessCreateRequest; +import com.cruru.process.controller.request.ProcessUpdateRequest; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.util.ControllerTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.ProcessFixture; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.restdocs.payload.FieldDescriptor; + +@DisplayName("프로세스 컨트롤러 테스트") +class ProcessControllerTest extends ControllerTest { + + private static final FieldDescriptor[] PROCESS_RESPONSE_FIELD_DESCRIPTORS = { + fieldWithPath("processId").description("프로세스의 id"), + fieldWithPath("orderIndex").description("프로세스의 순서"), + fieldWithPath("name").description("프로세스명"), + fieldWithPath("description").description("프로세스의 설명"), + fieldWithPath("applicants").description("프로세스에 속한 지원자들") + }; + + private static final FieldDescriptor[] APPLICANT_RESPONSE_FIELD_DESCRIPTORS = { + fieldWithPath("applicantId").description("지원자의 id"), + fieldWithPath("applicantName").description("지원자의 이름"), + fieldWithPath("createdAt").description("지원자의 지원날짜"), + fieldWithPath("isRejected").description("지원자의 불합격 여부"), + fieldWithPath("evaluationCount").description("지원자의 평가 개수"), + fieldWithPath("averageScore").description("지원자의 평가 평균 점수"), + }; + + private static final FieldDescriptor[] PROCESS_CREATE_FIELD_DESCRIPTORS = { + fieldWithPath("processName").description("프로세스명"), + fieldWithPath("description").description("프로세스의 설명"), + fieldWithPath("orderIndex").description("프로세스의 순서") + }; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + private Dashboard dashboard; + + @BeforeEach + void setUp() { + dashboard = dashboardRepository.save(DashboardFixture.backend(defaultClub)); + applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + } + + @DisplayName("프로세스 조회 성공 시, 200을 응답한다.") + @Test + void read() { + // given + List processes = processRepository.saveAll(List.of( + ProcessFixture.applyType(dashboard), + ProcessFixture.interview(dashboard), + ProcessFixture.applyType(dashboard) + )); + applicantRepository.save(ApplicantFixture.pendingDobby(processes.get(0))); + String url = String.format("/v1/processes?dashboardId=%d", dashboard.getId()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "process/read", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("dashboardId").description("대시보드의 id")), + responseFields( + fieldWithPath("applyFormId").description("지원폼의 id"), + fieldWithPath("processes").description("프로세스 목록"), + fieldWithPath("title").description("지원폼의 제목") + ).andWithPrefix("processes[].", PROCESS_RESPONSE_FIELD_DESCRIPTORS) + .andWithPrefix("processes[].applicants[]", APPLICANT_RESPONSE_FIELD_DESCRIPTORS) + )) + .when().get(url) + .then().log().all().statusCode(200); + } + + @DisplayName("프로세스 조회 실패 시, 404를 응답한다.") + @Test + void read_dashboardNotFound() { + // given + long invalidDashboardId = 0; + String url = String.format("/v1/processes?dashboardId=%d", invalidDashboardId); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "process/read-fail/dashboard-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("dashboardId").description("존재하지 않는 대시보드의 id")) + )) + .when().get(url) + .then().log().all().statusCode(404); + } + + @DisplayName("프로세스 생성 성공 시, 201을 응답한다.") + @Test + void create() { + // given + ProcessCreateRequest processCreateRequest = new ProcessCreateRequest("1차 면접", "화상 면접", 1); + String url = String.format("/v1/processes?dashboardId=%d", dashboard.getId()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(processCreateRequest) + .filter(document( + "process/create", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("dashboardId").description("대시보드의 id")), + requestFields(PROCESS_CREATE_FIELD_DESCRIPTORS) + )) + .when().post(url) + .then().log().all().statusCode(201); + } + + @DisplayName("프로세스 생성 시 대시보드가 존재하지 않을 경우, 404를 응답한다.") + @Test + void create_dashboardNotFound() { + // given + ProcessCreateRequest processCreateRequest = new ProcessCreateRequest("1차 면접", "화상 면접", 1); + String url = String.format("/v1/processes?dashboardId=%d", -1); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(processCreateRequest) + .filter(document( + "process/create-fail/dashboard-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("dashboardId").description("대시보드의 id")), + requestFields(PROCESS_CREATE_FIELD_DESCRIPTORS) + )) + .when().post(url) + .then().log().all().statusCode(404); + } + + @DisplayName("프로세스 생성 시 이름이 유효하지 않을 경우, 400를 응답한다.") + @Test + void create_invalidName() { + // given + ProcessCreateRequest processCreateRequest = new ProcessCreateRequest("", "화상 면접", 1); + String url = String.format("/v1/processes?dashboardId=%d", dashboard.getId()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(processCreateRequest) + .filter(document( + "process/create-fail/invalid-name", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("dashboardId").description("대시보드의 id")), + requestFields( + fieldWithPath("processName").description("부적절한 프로세스명"), + fieldWithPath("description").description("프로세스의 설명"), + fieldWithPath("orderIndex").description("프로세스의 순서") + ) + )) + .when().post(url) + .then().log().all().statusCode(400); + } + + @DisplayName("프로세스가 최대일 때 생성 시도 시, 400을 응답한다.") + @Test + void create_processCountOvered() { + // given + processRepository.saveAll(List.of( + ProcessFixture.applyType(dashboard), + ProcessFixture.interview(dashboard), + ProcessFixture.interview(dashboard), + ProcessFixture.interview(dashboard), + ProcessFixture.applyType(dashboard) + )); + + ProcessCreateRequest processCreateRequest = new ProcessCreateRequest("name", "description", 3); + String url = String.format("/v1/processes?dashboardId=%d", dashboard.getId()); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(processCreateRequest) + .filter(document( + "process/create-fail/process-count-overed/", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("dashboardId").description("생성할 프로세스의 대시보드 id")), + requestFields(PROCESS_CREATE_FIELD_DESCRIPTORS) + )) + .when().post(url) + .then().log().all().statusCode(400); + } + + @DisplayName("존재하는 프로세스의 이름과 설명 변경 성공시, 200을 응답한다.") + @Test + void update() { + // given + Process process = processRepository.save(ProcessFixture.applyType(dashboard)); + applicantRepository.save(ApplicantFixture.pendingDobby(process)); + ProcessUpdateRequest processUpdateRequest = new ProcessUpdateRequest("임시 과정", "수정된 프로세스"); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(processUpdateRequest) + .filter(document( + "process/update", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("processId").description("수정될 프로세스의 id")), + requestFields( + fieldWithPath("processName").description("프로세스명"), + fieldWithPath("description").description("프로세스의 설명") + ), + responseFields(PROCESS_RESPONSE_FIELD_DESCRIPTORS) + .andWithPrefix("applicants[].", APPLICANT_RESPONSE_FIELD_DESCRIPTORS) + )) + .when().patch("/v1/processes/{processId}", process.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("조건에 맞지 않는 이름으로 변경 시도 시, 400을 응답한다.") + @Test + void update_invalidName() { + // given + Process process = processRepository.save(ProcessFixture.applyType(dashboard)); + ProcessUpdateRequest processUpdateRequest = new ProcessUpdateRequest("", "description"); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(processUpdateRequest) + .filter(document( + "process/update-fail/invalid-name", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("processId").description("수정될 프로세스의 id")), + requestFields( + fieldWithPath("processName").description("조건에 맞지 않는 프로세스 이름"), + fieldWithPath("description").description("수정될 프로세스 설명") + ) + )) + .when().patch("/v1/processes/{processId}", process.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("존재하지 않는 프로세스 변경 시도 시, 404를 응답한다.") + @Test + void update_processNotFound() { + // given + Long invalidProcessId = -1L; + processRepository.save(ProcessFixture.applyType(dashboard)); + ProcessUpdateRequest processUpdateRequest = new ProcessUpdateRequest("임시 과정", "수정된 프로세스"); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(processUpdateRequest) + .filter(document( + "process/update-fail/process-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("processId").description("수정될 프로세스의 id")), + requestFields( + fieldWithPath("processName").description("조건에 맞지 않는 프로세스 이름"), + fieldWithPath("description").description("수정될 프로세스 설명") + ) + )) + .when().patch("/v1/processes/{processId}", invalidProcessId) + .then().log().all().statusCode(404); + } + + @DisplayName("프로세스 삭제 성공 시, 204를 응답한다.") + @Test + void delete() { + // given + Process process = processRepository.save(ProcessFixture.interview(dashboard)); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "process/delete", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("processId").description("생성할 프로세스의 대시보드 id")) + )) + .when().delete("/v1/processes/{processId}", process.getId()) + .then().log().all().statusCode(204); + } + + @DisplayName("존재하지 않는 프로세스 삭제 시도 시, 404를 응답한다.") + @Test + void delete_processNotFound() { + // given + Long invalidId = -1L; + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "process/delete-fail/process-not-found", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("processId").description("삭제할 프로세스의 id")) + )) + .when().delete("/v1/processes/{processId}", invalidId) + .then().log().all().statusCode(404); + } + + @DisplayName("처음 혹은 마지막 프로세스 삭제 시도 시, 400을 응답한다.") + @Test + void delete_endOrder() { + // given + Process process = processRepository.save(ProcessFixture.applyType(dashboard)); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "process/delete-fail/process-order-first-or-last", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("processId").description("삭제할 프로세스의 id")) + )) + .when().delete("/v1/processes/{processId}", process.getId()) + .then().log().all().statusCode(400); + } + + @DisplayName("지원자가 존재하는 프로세스 삭제 시도 시, 400을 응답한다.") + @Test + void delete_applicantExist() { + // given + Process process = processRepository.save(ProcessFixture.interview(dashboard)); + applicantRepository.save(ApplicantFixture.pendingDobby(process)); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .filter(document( + "process/delete-fail/process-applicant-exist", + requestCookies(cookieWithName("token").description("사용자 토큰")), + pathParameters(parameterWithName("processId").description("삭제할 프로세스의 id")) + )) + .when().delete("/v1/processes/{processId}", process.getId()) + .then().log().all().statusCode(400); + } +} diff --git a/backend/src/test/java/com/cruru/process/domain/ProcessTest.java b/backend/src/test/java/com/cruru/process/domain/ProcessTest.java new file mode 100644 index 000000000..acdbe0d50 --- /dev/null +++ b/backend/src/test/java/com/cruru/process/domain/ProcessTest.java @@ -0,0 +1,65 @@ +package com.cruru.process.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.process.exception.badrequest.ProcessNameBlankException; +import com.cruru.process.exception.badrequest.ProcessNameCharacterException; +import com.cruru.process.exception.badrequest.ProcessNameLengthException; +import com.cruru.util.fixture.DashboardFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("프로세스 도메인 테스트") +class ProcessTest { + + @DisplayName("프로세스 이름은 특수문자와 숫자를 허용한다.") + @ValueSource(strings = {"process!!", "(process!@#$%^&*)", "PROCESS123"}) + @ParameterizedTest + void validProcessName(String name) { + // given + Dashboard dashboard = DashboardFixture.backend(); + + // when&then + assertThatCode(() -> new Process(0, name, "desc", ProcessType.EVALUATE, dashboard)).doesNotThrowAnyException(); + } + + @DisplayName("프로세스 이름이 비어있으면 예외가 발생한다.") + @ValueSource(strings = {"", " "}) + @ParameterizedTest + void processNameBlank(String name) { + // given + Dashboard dashboard = new Dashboard(null); + + // when&then + assertThatThrownBy(() -> new Process(0, name, "desc", ProcessType.EVALUATE, dashboard)) + .isInstanceOf(ProcessNameBlankException.class); + } + + @DisplayName("프로세스 이름이 32자 초과시 예외가 발생한다.") + @Test + void invalidProcessNameLength() { + // given + Dashboard dashboard = new Dashboard(null); + String name = "ThisStringLengthIs33!!!!!!!!!!!!!"; + + // when&then + assertThatThrownBy(() -> new Process(0, name, "desc", ProcessType.EVALUATE, dashboard)) + .isInstanceOf(ProcessNameLengthException.class); + } + + @DisplayName("프로세스 이름에 허용되지 않은 글자가 들어가면 예외가 발생한다.") + @ValueSource(strings = {"invalidCharacter|", "invalidCharacter\\"}) + @ParameterizedTest + void invalidProcessNameCharacter(String name) { + // given + Dashboard dashboard = new Dashboard(null); + + // when&then + assertThatThrownBy(() -> new Process(0, name, "desc", ProcessType.EVALUATE, dashboard)) + .isInstanceOf(ProcessNameCharacterException.class); + } +} diff --git a/backend/src/test/java/com/cruru/process/domain/repository/ProcessRepositoryTest.java b/backend/src/test/java/com/cruru/process/domain/repository/ProcessRepositoryTest.java new file mode 100644 index 000000000..3d881c4d1 --- /dev/null +++ b/backend/src/test/java/com/cruru/process/domain/repository/ProcessRepositoryTest.java @@ -0,0 +1,57 @@ +package com.cruru.process.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.cruru.process.domain.Process; +import com.cruru.process.domain.ProcessType; +import com.cruru.util.RepositoryTest; +import com.cruru.util.fixture.ProcessFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("프로세스 레포지토리 테스트") +class ProcessRepositoryTest extends RepositoryTest { + + @Autowired + private ProcessRepository processRepository; + + @BeforeEach + void setUp() { + processRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어 있는 ID를 가진 프로세스를 저장하면, 해당 ID의 프로세스는 후에 작성된 정보로 업데이트한다.") + @Test + void sameIdUpdate() { + //given + Process process = ProcessFixture.applyType(); + Process saved = processRepository.save(process); + + //when + Process updatedProcess = new Process(saved.getId(), 1, "새로운 면접", "대면 면접", ProcessType.EVALUATE, null); + processRepository.save(updatedProcess); + + //then + Process findProcess = processRepository.findById(saved.getId()).get(); + assertThat(findProcess.getSequence()).isEqualTo(1); + assertThat(findProcess.getName()).isEqualTo("새로운 면접"); + assertThat(findProcess.getDescription()).isEqualTo("대면 면접"); + } + + @DisplayName("ID가 없는 프로세스를 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Process firstProcess = ProcessFixture.applyType(); + Process finalProcess = ProcessFixture.approveType(); + + //when + Process savedProcess1 = processRepository.save(firstProcess); + Process savedProcess2 = processRepository.save(finalProcess); + + //then + assertThat(savedProcess1.getId() + 1).isEqualTo(savedProcess2.getId()); + } +} diff --git a/backend/src/test/java/com/cruru/process/facade/ProcessFacadeTest.java b/backend/src/test/java/com/cruru/process/facade/ProcessFacadeTest.java new file mode 100644 index 000000000..a5afad632 --- /dev/null +++ b/backend/src/test/java/com/cruru/process/facade/ProcessFacadeTest.java @@ -0,0 +1,142 @@ +package com.cruru.process.facade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.controller.response.ApplicantCardResponse; +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applicant.domain.repository.EvaluationRepository; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.process.controller.request.ProcessCreateRequest; +import com.cruru.process.controller.request.ProcessUpdateRequest; +import com.cruru.process.controller.response.ProcessResponse; +import com.cruru.process.controller.response.ProcessResponses; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.EvaluationFixture; +import com.cruru.util.fixture.ProcessFixture; +import java.util.Comparator; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("프로세스 파사드 서비스 테스트") +class ProcessFacadeTest extends ServiceTest { + + @Autowired + private ProcessFacade processFacade; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private EvaluationRepository evaluationRepository; + + @DisplayName("새로운 프로세스를 생성한다.") + @Test + void create() { + // given + processRepository.save(ProcessFixture.applyType(defaultDashboard)); + processRepository.save(ProcessFixture.approveType(defaultDashboard)); + ProcessCreateRequest processCreateRequest = new ProcessCreateRequest("새로운 프로세스", "기존 2개의 프로세스 사이에 생성.", 1); + + // when + processFacade.create(processCreateRequest, defaultDashboard.getId()); + + // then + List allByDashboardId = processRepository.findAllByDashboardId(defaultDashboard.getId()) + .stream() + .sorted(Comparator.comparingInt(Process::getSequence)) + .toList(); + + String newProcessName = allByDashboardId.get(1).getName(); + assertAll( + () -> assertThat(allByDashboardId).hasSize(3), + () -> assertThat(newProcessName).isEqualTo("새로운 프로세스") + ); + } + + @DisplayName("ID에 해당하는 대시보드의 프로세스 목록과 지원자 정보를 조회한다.") + @Test + void readAllByDashboardId() { + // given + applyFormRepository.save(ApplyFormFixture.backend(defaultDashboard)); + Process process = processRepository.save(ProcessFixture.applyType(defaultDashboard)); + Process process1 = processRepository.save(ProcessFixture.interview(defaultDashboard)); + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + Applicant applicant1 = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + List evaluations = List.of( + EvaluationFixture.fivePoints(process, applicant), + EvaluationFixture.fourPoints(process, applicant), + EvaluationFixture.fourPoints(process, applicant), + EvaluationFixture.fourPoints(process, applicant) + ); + evaluationRepository.saveAll(evaluations); + + List evaluations1 = List.of( + EvaluationFixture.fivePoints(process1, applicant1), + EvaluationFixture.fivePoints(process1, applicant1), + EvaluationFixture.fivePoints(process1, applicant1), + EvaluationFixture.fourPoints(process1, applicant1) + ); + evaluationRepository.saveAll(evaluations1); + + // when + ProcessResponses processResponses = processFacade.readAllByDashboardId(defaultDashboard.getId()); + + // then + ProcessResponse firstProcessResponse = processResponses.processResponses().get(0); + long processId = firstProcessResponse.id(); + ApplicantCardResponse applicantCardResponse = firstProcessResponse.applicantCardResponses().get(0); + assertAll( + () -> assertThat(processResponses.processResponses()).hasSize(2), + () -> assertThat(processId).isEqualTo(process.getId()), + () -> assertThat(applicantCardResponse.id()).isEqualTo(applicant.getId()), + () -> assertThat(applicantCardResponse.evaluationCount()).isEqualTo(evaluations.size()), + () -> assertThat(applicantCardResponse.averageScore()).isEqualTo(4.25) + ); + } + + @DisplayName("프로세스 정보를 변경한다.") + @Test + void update() { + // given + Process process = processRepository.save(ProcessFixture.applyType(defaultDashboard)); + ProcessUpdateRequest processUpdateRequest = new ProcessUpdateRequest("면접 수정", "수정된 설명"); + + // when + Long processId = process.getId(); + ProcessResponse actualProcessResponse = processFacade.update(processUpdateRequest, processId); + + // then + assertAll( + () -> assertThat(actualProcessResponse.name()).isEqualTo(processUpdateRequest.name()), + () -> assertThat(actualProcessResponse.description()).isEqualTo(processUpdateRequest.description()) + ); + } + + @DisplayName("프로세스를 삭제한다.") + @Test + void delete() { + // given + Process process = processRepository.save(ProcessFixture.interview(defaultDashboard)); + + // when + processFacade.delete(process.getId()); + + // then + assertThat(processRepository.findAll()).isEmpty(); + } +} diff --git a/backend/src/test/java/com/cruru/process/service/ProcessServiceTest.java b/backend/src/test/java/com/cruru/process/service/ProcessServiceTest.java new file mode 100644 index 000000000..da4edac5f --- /dev/null +++ b/backend/src/test/java/com/cruru/process/service/ProcessServiceTest.java @@ -0,0 +1,175 @@ +package com.cruru.process.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.process.controller.request.ProcessCreateRequest; +import com.cruru.process.controller.request.ProcessUpdateRequest; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.process.exception.badrequest.ProcessCountException; +import com.cruru.process.exception.badrequest.ProcessDeleteFixedException; +import com.cruru.process.exception.badrequest.ProcessDeleteRemainingApplicantException; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.ProcessFixture; +import java.util.Comparator; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("프로세스 서비스 테스트") +class ProcessServiceTest extends ServiceTest { + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private ProcessService processService; + + @DisplayName("새로운 프로세스를 생성한다.") + @Test + void create() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + processRepository.save(ProcessFixture.applyType(dashboard)); + processRepository.save(ProcessFixture.approveType(dashboard)); + ProcessCreateRequest processCreateRequest = new ProcessCreateRequest("새로운 프로세스", "원래 있던 2개의 프로세스 사이에 생겼다.", 1); + + // when + processService.create(processCreateRequest, dashboard); + + // then + List allByDashboardId = processRepository.findAllByDashboardId(dashboard.getId()) + .stream() + .sorted(Comparator.comparingInt(Process::getSequence)) + .toList(); + + String actualName = allByDashboardId.get(1).getName(); + assertAll( + () -> assertThat(allByDashboardId).hasSize(3), + () -> assertThat(actualName).isEqualTo("새로운 프로세스") + ); + } + + @DisplayName("프로세스 최대 개수를 초과하면, 예외가 발생한다.") + @Test + void createOverProcessMaxCount() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + processRepository.saveAll(ProcessFixture.maxSizeOf(dashboard)); + ProcessCreateRequest processCreateRequest = new ProcessCreateRequest("2차 면접", "화상 면접", 1); + + // when&then + assertThatThrownBy(() -> processService.create(processCreateRequest, dashboard)) + .isInstanceOf(ProcessCountException.class); + } + + @DisplayName("프로세스를 ID를 통해 조회한다") + @Test + void findById() { + // given + Process savedProcess = processRepository.save(ProcessFixture.applyType()); + + // when&then + Long processId = savedProcess.getId(); + assertDoesNotThrow(() -> processService.findById(processId)); + assertThat(processService.findById(processId)).isEqualTo(savedProcess); + } + + @DisplayName("대시보드에 존재하는 첫 번째 프로세스를 조회한다.") + @Test + void findApplyProcessOnDashboard() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + Process firstProcess = processRepository.save(ProcessFixture.applyType(dashboard)); + processRepository.save(ProcessFixture.interview(dashboard)); + processRepository.save(ProcessFixture.approveType(dashboard)); + + // when + Process actualFirstProcess = processService.findApplyProcessOnDashboard(dashboard); + + // then + assertThat(actualFirstProcess).isEqualTo(firstProcess); + } + + @DisplayName("기존 정보에서 변경점이 있는 변경 요청시, 프로세스 정보를 변경한다.") + @Test + void update() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + Process process = processRepository.save(ProcessFixture.applyType(dashboard)); + ProcessUpdateRequest processUpdateRequest = new ProcessUpdateRequest("면접 수정", "수정된 설명"); + + // when + Long processId = process.getId(); + processService.update(processUpdateRequest, processId); + + // then + Process actualProcess = processRepository.findById(processId).get(); + assertAll( + () -> assertThat(actualProcess.getName()).isEqualTo(processUpdateRequest.name()), + () -> assertThat(actualProcess.getDescription()).isEqualTo(processUpdateRequest.description()) + ); + } + + @DisplayName("프로세스를 삭제한다.") + @Test + void delete() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + Process process = processRepository.save(ProcessFixture.interview(dashboard)); + + // when + processService.delete(process.getId()); + + // then + assertThat(processRepository.findAll()).isEmpty(); + } + + @DisplayName("첫번째 프로세스 혹은 마지막 프로세스를 삭제하면 예외가 발생한다.") + @Test + void delete_FirstOrLastProcess_ThrowsException() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + Process firstProcess = processRepository.save(ProcessFixture.applyType(dashboard)); + Process finalProcess = processRepository.save(ProcessFixture.approveType(dashboard)); + + // when & then + Long firstProcessId = firstProcess.getId(); + Long finalProcessId = finalProcess.getId(); + assertAll( + () -> assertThatThrownBy(() -> processService.delete(firstProcessId)) + .isInstanceOf(ProcessDeleteFixedException.class), + () -> assertThatThrownBy(() -> processService.delete(finalProcessId)) + .isInstanceOf(ProcessDeleteFixedException.class) + ); + } + + @DisplayName("삭제하려는 프로세스에 해당되는 지원자가 있을 경우 예외가 발생한다.") + @Test + void delete_ApplicantRemainedProcess_ThrowsException() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + Process process = processRepository.save(ProcessFixture.interview(dashboard)); + applicantRepository.save(ApplicantFixture.pendingDobby(process)); + + // when&then + Long processId = process.getId(); + assertThatThrownBy(() -> processService.delete(processId)) + .isInstanceOf(ProcessDeleteRemainingApplicantException.class); + } +} diff --git a/backend/src/test/java/com/cruru/question/controller/QuestionControllerTest.java b/backend/src/test/java/com/cruru/question/controller/QuestionControllerTest.java new file mode 100644 index 000000000..1b81f9296 --- /dev/null +++ b/backend/src/test/java/com/cruru/question/controller/QuestionControllerTest.java @@ -0,0 +1,119 @@ +package com.cruru.question.controller; + +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.question.controller.request.ChoiceCreateRequest; +import com.cruru.question.controller.request.QuestionCreateRequest; +import com.cruru.question.controller.request.QuestionUpdateRequests; +import com.cruru.question.domain.QuestionType; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.util.ControllerTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.QuestionFixture; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.restdocs.payload.FieldDescriptor; + +@DisplayName("질문 컨트롤러 테스트") +class QuestionControllerTest extends ControllerTest { + + private static final FieldDescriptor[] QUESTION_FIELD_DESCRIPTORS = { + fieldWithPath("type").description("질문의 유형"), + fieldWithPath("question").description("질문 내용"), + fieldWithPath("choices").description("질문의 선택지들"), + fieldWithPath("orderIndex").description("질문의 순서"), + fieldWithPath("required").description("질문의 필수 여부") + }; + + private static final FieldDescriptor[] CHOICE_FIELD_DESCRIPTORS = { + fieldWithPath("choice").description("선택지의 내용"), + fieldWithPath("orderIndex").description("선택지의 순서") + }; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @DisplayName("존재하는 질문의 변경 성공시, 200을 응답한다.") + @Test + void update() { + // given + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.notStarted()); + questionRepository.save(QuestionFixture.multipleChoiceType(applyForm)); + QuestionUpdateRequests questionUpdateRequests = new QuestionUpdateRequests( + List.of( + new QuestionCreateRequest( + QuestionType.LONG_ANSWER.name(), + "new", + List.of(new ChoiceCreateRequest("좋아하는 음식은?", 0)), + 0, + true + ) + ) + ); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(questionUpdateRequests) + .filter(document("question/update", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("applyformId").description("질문을 변경할 지원폼의 id")), + requestFields(fieldWithPath("questions").description("변경할 질문들")) + .andWithPrefix("questions[].", QUESTION_FIELD_DESCRIPTORS) + .andWithPrefix("questions[].choices[].", CHOICE_FIELD_DESCRIPTORS) + )) + .when().patch("/v1/questions?applyformId={applyformId}", applyForm.getId()) + .then().log().all().statusCode(200); + } + + @DisplayName("존재하는 않는 지원폼의 질문 변경 시, 404를 응답한다.") + @Test + void update_applyFormNotFound() { + // given + long invalidApplyFormId = -1; + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.notStarted()); + questionRepository.save(QuestionFixture.multipleChoiceType(applyForm)); + QuestionUpdateRequests questionUpdateRequests = new QuestionUpdateRequests( + List.of( + new QuestionCreateRequest( + QuestionType.LONG_ANSWER.name(), + "new", + List.of(new ChoiceCreateRequest("좋아하는 음식은?", 0)), + 0, + true + ) + ) + ); + + // when&then + RestAssured.given(spec).log().all() + .cookie("token", token) + .contentType(ContentType.JSON) + .body(questionUpdateRequests) + .filter(document("question/update", + requestCookies(cookieWithName("token").description("사용자 토큰")), + queryParameters(parameterWithName("applyformId").description("존재하지 않는 지원폼의 id")), + requestFields(fieldWithPath("questions").description("변경할 질문들")) + .andWithPrefix("questions[].", QUESTION_FIELD_DESCRIPTORS) + .andWithPrefix("questions[].choices[].", CHOICE_FIELD_DESCRIPTORS) + )) + .when().patch("/v1/questions?applyformId={applyformId}", invalidApplyFormId) + .then().log().all().statusCode(404); + } +} diff --git a/backend/src/test/java/com/cruru/question/domain/AnswerTest.java b/backend/src/test/java/com/cruru/question/domain/AnswerTest.java new file mode 100644 index 000000000..2bc9b98f0 --- /dev/null +++ b/backend/src/test/java/com/cruru/question/domain/AnswerTest.java @@ -0,0 +1,44 @@ +package com.cruru.question.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.cruru.question.exception.AnswerContentLengthException; +import com.cruru.util.fixture.QuestionFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("답변 도메인 테스트") +class AnswerTest { + + @DisplayName("질문 형식이 단답형인 경우, 답변 내용의 길이가 50자를 초과하면 예외가 발생한다.") + @Test + void createShortAnswer_invalidContentLength() { + // given + String invalidContent = "ThisTextIsGreaterThan50CharactersSoExceptionOccurs!"; + Question question = QuestionFixture.shortAnswerType(null); + + // when&then + assertThatThrownBy(() -> new Answer(invalidContent, question, null)) + .isInstanceOf(AnswerContentLengthException.class); + } + + @DisplayName("질문 형식이 장문형인 경우, 답변 내용의 길이가 1,000자를 초과하면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"0123456789", "가나다라마바사아자차", "abcdefghij"}) + void createLongAnswer_invalidContentLength(String content) { + // given + int repeatCount = 100; + StringBuilder stringBuilder = new StringBuilder(content.length() * repeatCount); + for (int i = 0; i < repeatCount; i++) { + stringBuilder.append(content); + } + String invalidContent = stringBuilder.append("!").toString(); + Question question = QuestionFixture.longAnswerType(null); + + // when&then + assertThatThrownBy(() -> new Answer(invalidContent, question, null)) + .isInstanceOf(AnswerContentLengthException.class); + } +} diff --git a/backend/src/test/java/com/cruru/question/domain/repository/AnswerRepositoryTest.java b/backend/src/test/java/com/cruru/question/domain/repository/AnswerRepositoryTest.java new file mode 100644 index 000000000..860d17f19 --- /dev/null +++ b/backend/src/test/java/com/cruru/question/domain/repository/AnswerRepositoryTest.java @@ -0,0 +1,96 @@ +package com.cruru.question.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.repository.ProcessRepository; +import com.cruru.question.domain.Answer; +import com.cruru.question.domain.Question; +import com.cruru.util.RepositoryTest; +import com.cruru.util.fixture.AnswerFixture; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.ProcessFixture; +import com.cruru.util.fixture.QuestionFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("주관식 답변 레포지토리 테스트") +class AnswerRepositoryTest extends RepositoryTest { + + @Autowired + private AnswerRepository answerRepository; + + @Autowired + private ProcessRepository processRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private QuestionRepository questionRepository; + + @BeforeEach + void setUp() { + answerRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어 있는 ID를 가진 답변을 저장하면, 해당 ID의 답변은 후에 작성된 정보로 업데이트한다.") + @Test + void sameIdUpdate() { + //given + Question question = QuestionFixture.shortAnswerType(null); + Answer answer = new Answer("체육 전공입니다.", question, null); + Answer saved = answerRepository.save(answer); + + //when + Answer updateAnswer = new Answer(saved.getId(), "음악 전공입니다.", null, null); + answerRepository.save(updateAnswer); + + //then + Answer foundAnswer = answerRepository.findById(saved.getId()).get(); + assertThat(foundAnswer.getContent()).isEqualTo("음악 전공입니다."); + } + + @DisplayName("ID가 없는 답변을 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Question question = QuestionFixture.shortAnswerType(null); + Answer answer1 = new Answer("체육 전공입니다.", question, null); + Answer answer2 = new Answer("음악 전공입니다.", question, null); + + //when + Answer savedAnswer1 = answerRepository.save(answer1); + Answer savedAnswer2 = answerRepository.save(answer2); + + //then + assertThat(savedAnswer1.getId() + 1).isEqualTo(savedAnswer2.getId()); + } + + @DisplayName("특정 지원자와 질문에 해당하는 답변 목록을 조회한다.") + @Test + void findAllByApplicantWithQuestions() { + // given + Process process = processRepository.save(ProcessFixture.applyType()); + Applicant applicant = applicantRepository.save(ApplicantFixture.pendingDobby(process)); + Question question = questionRepository.save(QuestionFixture.shortAnswerType(null)); + answerRepository.save(AnswerFixture.first(question, applicant)); + + // when + List actual = answerRepository.findAllByApplicantWithQuestions(applicant); + + // then + Answer actualAnswer = actual.get(0); + assertAll( + () -> assertThat(actual).hasSize(1), + () -> assertThat(actualAnswer.getQuestion()).isEqualTo(question), + () -> assertThat(actualAnswer.getApplicant()).isEqualTo(applicant) + ); + } +} diff --git a/backend/src/test/java/com/cruru/question/domain/repository/ChoiceRepositoryTest.java b/backend/src/test/java/com/cruru/question/domain/repository/ChoiceRepositoryTest.java new file mode 100644 index 000000000..e415cd1f5 --- /dev/null +++ b/backend/src/test/java/com/cruru/question/domain/repository/ChoiceRepositoryTest.java @@ -0,0 +1,53 @@ +package com.cruru.question.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.cruru.question.domain.Choice; +import com.cruru.util.RepositoryTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("객관식 선택지 레포지토리 테스트") +class ChoiceRepositoryTest extends RepositoryTest { + + @Autowired + private ChoiceRepository choiceRepository; + + @BeforeEach + void setUp() { + choiceRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어 있는 ID를 가진 선택지를 저장하면, 해당 ID의 선택지를 후에 작성된 정보로 업데이트한다.") + @Test + void sameIdUpdate() { + //given + Choice choice = new Choice("남자", 0, null); + Choice saved = choiceRepository.save(choice); + + //when + Choice updateChoice = new Choice(saved.getId(), "여자", 1, null); + choiceRepository.save(updateChoice); + + //then + Choice findChoice = choiceRepository.findById(saved.getId()).get(); + assertThat(findChoice.getContent()).isEqualTo("여자"); + } + + @DisplayName("ID가 없는 선택지를 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Choice choice1 = new Choice("남자", 0, null); + Choice choice2 = new Choice("여자", 1, null); + + //when + Choice savedChoice1 = choiceRepository.save(choice1); + Choice savedChoice2 = choiceRepository.save(choice2); + + //then + assertThat(savedChoice1.getId() + 1).isEqualTo(savedChoice2.getId()); + } +} diff --git a/backend/src/test/java/com/cruru/question/domain/repository/QuestionRepositoryTest.java b/backend/src/test/java/com/cruru/question/domain/repository/QuestionRepositoryTest.java new file mode 100644 index 000000000..cb3a58189 --- /dev/null +++ b/backend/src/test/java/com/cruru/question/domain/repository/QuestionRepositoryTest.java @@ -0,0 +1,56 @@ +package com.cruru.question.domain.repository; + +import static com.cruru.question.domain.QuestionType.DROPDOWN; +import static com.cruru.question.domain.QuestionType.SHORT_ANSWER; +import static org.assertj.core.api.Assertions.assertThat; + +import com.cruru.question.domain.Question; +import com.cruru.util.RepositoryTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("질문 레포지토리 테스트") +class QuestionRepositoryTest extends RepositoryTest { + + @Autowired + private QuestionRepository questionRepository; + + @BeforeEach + void setUp() { + questionRepository.deleteAllInBatch(); + } + + @DisplayName("이미 DB에 저장되어 있는 ID를 가진 질문을 저장하면, 해당 ID의 질문은 후에 작성된 정보로 업데이트한다.") + @Test + void sameIdUpdate() { + //given + Question question = new Question(DROPDOWN, "성별", 0, false, null); + Question saved = questionRepository.save(question); + + //when + Question updateQuestion = new Question(saved.getId(), SHORT_ANSWER, "전공", 1, false, null); + questionRepository.save(updateQuestion); + + //then + Question findQuestion = questionRepository.findById(saved.getId()).get(); + assertThat(findQuestion.getContent()).isEqualTo("전공"); + assertThat(findQuestion.getSequence()).isEqualTo(1); + } + + @DisplayName("ID가 없는 질문을 저장하면, ID를 순차적으로 부여하여 저장한다.") + @Test + void saveNoId() { + //given + Question question1 = new Question(DROPDOWN, "성별", 0, false, null); + Question question2 = new Question(SHORT_ANSWER, "전공", 1, false, null); + + //when + Question savedQuestion1 = questionRepository.save(question1); + Question savedQuestion2 = questionRepository.save(question2); + + //then + assertThat(savedQuestion1.getId() + 1).isEqualTo(savedQuestion2.getId()); + } +} diff --git a/backend/src/test/java/com/cruru/question/facade/QuestionFacadeTest.java b/backend/src/test/java/com/cruru/question/facade/QuestionFacadeTest.java new file mode 100644 index 000000000..4e670c0f3 --- /dev/null +++ b/backend/src/test/java/com/cruru/question/facade/QuestionFacadeTest.java @@ -0,0 +1,110 @@ +package com.cruru.question.facade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.question.controller.request.ChoiceCreateRequest; +import com.cruru.question.controller.request.QuestionCreateRequest; +import com.cruru.question.controller.request.QuestionUpdateRequests; +import com.cruru.question.domain.Choice; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.ChoiceRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.question.exception.QuestionUnmodifiableException; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.ChoiceFixture; +import com.cruru.util.fixture.QuestionFixture; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("질문 파사드 테스트") +class QuestionFacadeTest extends ServiceTest { + + @Autowired + private QuestionFacade questionFacade; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private ChoiceRepository choiceRepository; + + @DisplayName("질문을 수정한다.") + @Test + void update() { + // given + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.notStarted()); + Question question = questionRepository.save(QuestionFixture.multipleChoiceType(applyForm)); + choiceRepository.save(ChoiceFixture.first(question)); + choiceRepository.save(ChoiceFixture.second(question)); + + Question newQuestion = QuestionFixture.singleChoiceType(applyForm); + Choice newChoice = ChoiceFixture.third(question); + + QuestionUpdateRequests questionUpdateRequest = new QuestionUpdateRequests(List.of( + new QuestionCreateRequest( + newQuestion.getQuestionType().name(), + newQuestion.getContent(), + List.of(new ChoiceCreateRequest( + newChoice.getContent(), + newChoice.getSequence() + )), + newQuestion.getSequence(), + newQuestion.isRequired() + ))); + + // when + questionFacade.update(questionUpdateRequest, applyForm.getId()); + + // then + List actualQuestions = questionRepository.findAllByApplyForm(applyForm); + Question actualQuestion = actualQuestions.get(0); + List actualChoices = choiceRepository.findAllByQuestion(actualQuestion); + Choice actualChoice = actualChoices.get(0); + assertAll( + () -> assertThat(actualQuestions).hasSize(1), + () -> assertThat(actualQuestion.getQuestionType()).isEqualTo(newQuestion.getQuestionType()), + () -> assertThat(actualQuestion.getContent()).isEqualTo(newQuestion.getContent()), + () -> assertThat(actualQuestion.getSequence()).isEqualTo(newQuestion.getSequence()), + () -> assertThat(actualQuestion.isRequired()).isEqualTo(newQuestion.isRequired()), + + () -> assertThat(actualChoices).hasSize(1), + () -> assertThat(actualChoice.getContent()).isEqualTo(newChoice.getContent()), + () -> assertThat(actualChoice.getSequence()).isEqualTo(newChoice.getSequence()) + ); + } + + @DisplayName("모집 공고가 시작된 이후이면 질문을 수정할 수 없다.") + @Test + void update_ApplyFormInProgress() { + // given + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend()); + Question question = questionRepository.save(QuestionFixture.multipleChoiceType(applyForm)); + choiceRepository.save(ChoiceFixture.first(question)); + choiceRepository.save(ChoiceFixture.second(question)); + + Question newQuestion = QuestionFixture.shortAnswerType(applyForm); + + QuestionUpdateRequests questionUpdateRequest = new QuestionUpdateRequests(List.of( + new QuestionCreateRequest( + newQuestion.getQuestionType().name(), + newQuestion.getContent(), + List.of(), + newQuestion.getSequence(), + newQuestion.isRequired() + ))); + + // when&then + assertThatThrownBy(() -> questionFacade.update(questionUpdateRequest, applyForm.getId())) + .isInstanceOf(QuestionUnmodifiableException.class); + } +} diff --git a/backend/src/test/java/com/cruru/question/service/AnswerServiceTest.java b/backend/src/test/java/com/cruru/question/service/AnswerServiceTest.java new file mode 100644 index 000000000..4a8edc112 --- /dev/null +++ b/backend/src/test/java/com/cruru/question/service/AnswerServiceTest.java @@ -0,0 +1,169 @@ +package com.cruru.question.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.repository.ApplicantRepository; +import com.cruru.applyform.controller.request.AnswerCreateRequest; +import com.cruru.applyform.exception.badrequest.ReplyNotExistsException; +import com.cruru.question.controller.response.AnswerResponse; +import com.cruru.question.domain.Answer; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.AnswerRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.AnswerFixture; +import com.cruru.util.fixture.ApplicantFixture; +import com.cruru.util.fixture.QuestionFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("Answer 서비스 테스트") +class AnswerServiceTest extends ServiceTest { + + @Autowired + private AnswerService answerService; + + @Autowired + private AnswerRepository answerRepository; + + @Autowired + private ApplicantRepository applicantRepository; + + @Autowired + private QuestionRepository questionRepository; + + private Applicant applicant; + private Question question1; + private Question question2; + + @BeforeEach + void setUp() { + applicant = applicantRepository.save(ApplicantFixture.pendingDobby()); + question1 = questionRepository.save(QuestionFixture.shortAnswerType(null)); + question2 = questionRepository.save(QuestionFixture.dropdownType(null)); + } + + @DisplayName("질문에 대한 지원자의 답변을 성공적으로 저장한다.") + @Test + void savedAnswerReplies() { + // given + String reply = "첫 번째 단답"; + List replies1 = List.of(reply); + AnswerCreateRequest request = new AnswerCreateRequest(question1.getId(), replies1); + + // when + answerService.saveAnswerReplies(request, question1, applicant); + + // then + List actualAnswer = answerRepository.findAllByApplicantWithQuestions(applicant); + String content = actualAnswer.get(0).getContent(); + assertAll( + () -> assertThat(actualAnswer).hasSize(1), + () -> assertThat(content).isEqualTo(reply) + ); + } + + @DisplayName("선택 질문에 대한 지원자의 답변이 존재하지 않으면 빈 문자열을 저장한다.") + @Test + void savedAnswerReplies_notRequiredReplyNotExists() { + // given + List replies = List.of(); + Question question = questionRepository.save(QuestionFixture.shortAnswerType(null)); + AnswerCreateRequest request = new AnswerCreateRequest(question.getId(), replies); + + // then + answerService.saveAnswerReplies(request, question, applicant); + + // then + List actualAnswer = answerRepository.findAllByApplicantWithQuestions(applicant); + String content = actualAnswer.get(0).getContent(); + assertAll( + () -> assertThat(actualAnswer).hasSize(1), + () -> assertThat(content).isEqualTo("") + ); + } + + @DisplayName("필수 질문에 대한 지원자의 답변이 존재하지 않으면 예외가 발생한다.") + @Test + void savedAnswerReplies_requiredReplyNotExists() { + // given + List replies = List.of(); + Question question = questionRepository.save(QuestionFixture.required(null)); + AnswerCreateRequest request = new AnswerCreateRequest(question.getId(), replies); + + // when&then + assertThatThrownBy(() -> answerService.saveAnswerReplies(request, question, applicant)) + .isInstanceOf(ReplyNotExistsException.class); + } + + @DisplayName("지원자의 응답을 조회한다.") + @Test + void findAllByApplicant() { + // given + List expectedAnswers = answerRepository.saveAll(List.of( + AnswerFixture.first(question1, applicant), + AnswerFixture.second(question2, applicant) + )); + + // when + List actualAnswers = answerService.findAllByApplicantWithQuestions(applicant); + + // then + assertThat(actualAnswers).hasSameElementsAs(expectedAnswers); + } + + @DisplayName("도메인 엔티티를 DTO로 변환한다.") + @Test + void toAnswerResponses() { + // given + Answer expectedAnswer1 = AnswerFixture.first(question1, applicant); + Answer expectedAnswer2 = AnswerFixture.second(question2, applicant); + List expectedAnswers = List.of(expectedAnswer1, expectedAnswer2); + + // when + List actualAnswerResponses = answerService.toAnswerResponses(expectedAnswers); + + // then + AnswerResponse actualAnswerResponse1 = actualAnswerResponses.get(0); + AnswerResponse actualAnswerResponse2 = actualAnswerResponses.get(1); + assertAll( + () -> assertThat(actualAnswerResponses).hasSize(2), + + () -> assertThat(actualAnswerResponse1.answer()).isEqualTo(expectedAnswer1.getContent()), + () -> assertThat(actualAnswerResponse2.answer()).isEqualTo(expectedAnswer2.getContent()), + + () -> assertThat(actualAnswerResponse1.question()).isEqualTo(question1.getContent()), + () -> assertThat(actualAnswerResponse2.question()).isEqualTo(question2.getContent()) + ); + } + + @DisplayName("다중 선택 답변은 하나의 답변으로 묶어 DTO로 변환한다.") + @Test + void toAnswerResponses_multipleChoice() { + // given + Question question = questionRepository.save(QuestionFixture.multipleChoiceType(null)); + + Answer expectedAnswer1 = AnswerFixture.first(question, applicant); + Answer expectedAnswer2 = AnswerFixture.second(question, applicant); + Answer expectedAnswer3 = AnswerFixture.second(question, applicant); + + List expectedAnswers = List.of(expectedAnswer1, expectedAnswer2, expectedAnswer3); + + // when + List actualAnswerResponses = answerService.toAnswerResponses(expectedAnswers); + + // then + assertAll( + () -> assertThat(actualAnswerResponses).hasSize(1), + () -> assertThat(actualAnswerResponses.get(0).answer()).contains(expectedAnswer1.getContent()), + () -> assertThat(actualAnswerResponses.get(0).answer()).contains(expectedAnswer2.getContent()), + () -> assertThat(actualAnswerResponses.get(0).answer()).contains(expectedAnswer3.getContent()) + ); + } +} diff --git a/backend/src/test/java/com/cruru/question/service/ChoiceServiceTest.java b/backend/src/test/java/com/cruru/question/service/ChoiceServiceTest.java new file mode 100644 index 000000000..0714e3930 --- /dev/null +++ b/backend/src/test/java/com/cruru/question/service/ChoiceServiceTest.java @@ -0,0 +1,124 @@ +package com.cruru.question.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.question.controller.request.ChoiceCreateRequest; +import com.cruru.question.domain.Choice; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.ChoiceRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.question.exception.badrequest.ChoiceEmptyException; +import com.cruru.question.exception.badrequest.ChoiceIllegalSaveException; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.ChoiceFixture; +import com.cruru.util.fixture.QuestionFixture; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("선택지 서비스 테스트") +class ChoiceServiceTest extends ServiceTest { + + @Autowired + private ChoiceService choiceService; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ChoiceRepository choiceRepository; + + @DisplayName("특정 객관식 Question에 속하는 다수의 선택지를 저장한다.") + @Test + void createAll() { + // given + Question question = questionRepository.save(QuestionFixture.dropdownType(null)); + List choices = ChoiceFixture.fiveChoices(question); + List choiceRequests = choices.stream() + .map(choice -> new ChoiceCreateRequest(choice.getContent(), choice.getSequence())) + .toList(); + + // when + List actualChoices = choiceService.createAll(choiceRequests, question); + + // then + + assertThat(actualChoices).hasSize(choices.size()); + } + + @DisplayName("주관식 Question의 선택지를 저장을 시도하면 예외를 던진다.") + @Test + void createAllThrowsWithIllegalSaveException() { + // given + Question shortAnswerQuestion = questionRepository.save(QuestionFixture.shortAnswerType(null)); + Question longAnswerQuestion = questionRepository.save(QuestionFixture.longAnswerType(null)); + List choices = ChoiceFixture.fiveChoices(shortAnswerQuestion); + List choiceRequests = choices.stream() + .map(choice -> new ChoiceCreateRequest(choice.getContent(), choice.getSequence())) + .toList(); + + // when & then + assertAll( + () -> assertThatThrownBy(() -> choiceService.createAll(choiceRequests, shortAnswerQuestion)) + .isInstanceOf(ChoiceIllegalSaveException.class), + + () -> assertThatThrownBy(() -> choiceService.createAll(choiceRequests, longAnswerQuestion)) + .isInstanceOf(ChoiceIllegalSaveException.class) + ); + } + + @DisplayName("객관식 Question에 선택지가 존재하지 않으면 예외를 던진다.") + @Test + void createAllThrowsWithChoiceEmptyBadRequestException() { + // given + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(defaultDashboard)); + Question dropdownQuestion = questionRepository.save(QuestionFixture.dropdownType(applyForm)); + List choiceRequests = List.of(); + + // when&then + assertThatThrownBy(() -> choiceService.createAll(choiceRequests, dropdownQuestion)) + .isInstanceOf(ChoiceEmptyException.class); + } + + @DisplayName("객관식 질문의 모든 선택지를 조회한다.") + @Test + void findAllByQuestion() { + // given + Question question = questionRepository.save(QuestionFixture.dropdownType(null)); + List choices = choiceRepository.saveAll(ChoiceFixture.fiveChoices(question)); + + // when + List actualChoices = choiceService.findAllByQuestion(question); + + // then + int expectedSize = choices.size(); + assertThat(actualChoices).hasSize(expectedSize); + } + + @DisplayName("해당 question의 선택지를 모두 삭제한다.") + @Test + void deleteAllByQuestion() { + // given + Question question = questionRepository.save(QuestionFixture.multipleChoiceType(null)); + choiceRepository.save(ChoiceFixture.second(question)); + + // when + choiceService.deleteAllByQuestion(question); + + // then + assertThat(choiceRepository.findAllByQuestion(question)).isEmpty(); + } +} diff --git a/backend/src/test/java/com/cruru/question/service/QuestionServiceTest.java b/backend/src/test/java/com/cruru/question/service/QuestionServiceTest.java new file mode 100644 index 000000000..cc02402bf --- /dev/null +++ b/backend/src/test/java/com/cruru/question/service/QuestionServiceTest.java @@ -0,0 +1,153 @@ +package com.cruru.question.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.applyform.domain.repository.ApplyFormRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.question.controller.request.QuestionCreateRequest; +import com.cruru.question.controller.response.ChoiceResponse; +import com.cruru.question.controller.response.QuestionResponse; +import com.cruru.question.domain.Question; +import com.cruru.question.domain.repository.ChoiceRepository; +import com.cruru.question.domain.repository.QuestionRepository; +import com.cruru.util.ServiceTest; +import com.cruru.util.fixture.ApplyFormFixture; +import com.cruru.util.fixture.ChoiceFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.QuestionFixture; +import java.util.Comparator; +import java.util.List; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; + +@TestInstance(Lifecycle.PER_CLASS) +@DisplayName("질문 서비스 테스트") +class QuestionServiceTest extends ServiceTest { + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private ApplyFormRepository applyFormRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + @Autowired + private ChoiceRepository choiceRepository; + + @Autowired + private QuestionService questionService; + + @DisplayName("질문 생성에 성공한다.") + @Test + void create() { + // given + Dashboard dashboard = dashboardRepository.save(DashboardFixture.backend()); + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend(dashboard)); + Question question1 = QuestionFixture.shortAnswerType(applyForm); + QuestionCreateRequest request = new QuestionCreateRequest( + question1.getQuestionType().toString(), + question1.getContent(), + List.of(), + 0, + question1.isRequired() + ); + + // when + Question question = questionService.create(request, applyForm); + + // then + List questions = questionRepository.findAllByApplyForm(applyForm); + assertThat(questions).hasSize(1); + assertThat(questions.get(0)).isEqualTo(question); + } + + @DisplayName("질문 ID를 통해 특정 질문을 조회한다.") + @Test + void findById() { + // given + Question savedQuestion = questionRepository.save(QuestionFixture.longAnswerType(null)); + + // when&then + assertDoesNotThrow(() -> questionService.findById(savedQuestion.getId())); + Question actualFoundQuestion = questionService.findById(savedQuestion.getId()); + assertThat(actualFoundQuestion).isEqualTo(savedQuestion); + } + + @DisplayName("Question 엔티티의 정보를 이용하여 Response DTO로 변경한다.") + @ParameterizedTest() + @MethodSource("provideQuestionsAndResponses") + void toQuestionResponse(Question expectedQuestion, QuestionResponse actualResponse) { + // given&when&then + assertAll( + () -> assertThat(actualResponse.id()).isEqualTo(expectedQuestion.getId()), + () -> assertThat(actualResponse.orderIndex()).isEqualTo(expectedQuestion.getSequence()), + () -> assertThat(actualResponse.type()).isEqualTo(expectedQuestion.getQuestionType().toString()), + () -> assertThat(actualResponse.content()).isEqualTo(expectedQuestion.getContent()) + ); + } + + private Stream provideQuestionsAndResponses() { + List savedQuestions = questionRepository.saveAll(QuestionFixture.allTypes(null)) + .stream() + .sorted(Comparator.comparing(Question::getSequence)) + .toList(); + + List questionResponses = questionService.toQuestionResponses(savedQuestions) + .stream() + .sorted(Comparator.comparing(QuestionResponse::orderIndex)) + .toList(); + + return IntStream.range(0, savedQuestions.size()) + .mapToObj(i -> Arguments.of(savedQuestions.get(i), questionResponses.get(i))); + } + + @DisplayName("선택지를 가지고 있지 않는 질문은 ChoiceResponse 목록이 비어있어야 한다.") + @Test + void toQuestionResponse_NotHavingChoicesQuestion() { + // given + List nonChoiceTypeQuestions = questionRepository.saveAll(QuestionFixture.nonChoiceType(null)); + + // when + List questionResponses = questionService.toQuestionResponses(nonChoiceTypeQuestions); + + // then + List choiceResponses1 = questionResponses.get(0).choiceResponses(); + List choiceResponses2 = questionResponses.get(1).choiceResponses(); + assertAll( + () -> assertThat(choiceResponses1).isEmpty(), + () -> assertThat(choiceResponses2).isEmpty() + ); + } + + @DisplayName("해당 ApplyForm의 Question을 모두 삭제한다.") + @Test + void deleteAllByApplyForm() { + // given + ApplyForm applyForm = applyFormRepository.save(ApplyFormFixture.backend()); + Question question = questionRepository.save(QuestionFixture.singleChoiceType(applyForm)); + choiceRepository.save(ChoiceFixture.first(question)); + + // when + questionService.deleteAllByApplyForm(applyForm); + + // then + assertAll( + () -> assertThat(questionRepository.findAllByApplyForm(applyForm)).isEmpty(), + () -> assertThat(choiceRepository.findAllByQuestion(question)).isEmpty() + ); + } +} diff --git a/backend/src/test/java/com/cruru/util/ControllerTest.java b/backend/src/test/java/com/cruru/util/ControllerTest.java new file mode 100644 index 000000000..66659a2f6 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/ControllerTest.java @@ -0,0 +1,98 @@ +package com.cruru.util; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.documentationConfiguration; + +import com.cruru.auth.service.AuthService; +import com.cruru.club.domain.Club; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.util.fixture.ClubFixture; +import com.cruru.util.fixture.LocalDateFixture; +import com.cruru.util.fixture.MemberFixture; +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.specification.RequestSpecification; +import jakarta.mail.internet.MimeMessage; +import java.time.Clock; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@ExtendWith({RestDocumentationExtension.class, SpringExtension.class}) +public class ControllerTest { + + private static final Clock FIXED_TIME = LocalDateFixture.fixedClock(); + + protected RequestSpecification spec; + protected Member defaultMember; + protected Club defaultClub; + protected String token; + + @LocalServerPort + private int port; + @Autowired + private AuthService authService; + @Autowired + private MemberRepository memberRepository; + @Autowired + private ClubRepository clubRepository; + @Autowired + private DbCleaner dbCleaner; + @SpyBean + private Clock clock; + @MockBean + private JavaMailSender javaMailSender; + + @BeforeEach + void createDefaultLoginMember() { + dbCleaner.truncateEveryTable(); + defaultMember = memberRepository.save(MemberFixture.ADMIN); + defaultClub = clubRepository.save(ClubFixture.create(defaultMember)); + token = authService.createToken(defaultMember); + } + + @BeforeEach + void setPort(RestDocumentationContextProvider restDocumentation) { + RestAssured.port = port; + spec = new RequestSpecBuilder() + .addFilter( + documentationConfiguration(restDocumentation) + .operationPreprocessors() + .withRequestDefaults(prettyPrint()) + .withResponseDefaults(prettyPrint()) + ).build(); + } + + @BeforeEach + void setClock() { + doReturn(Instant.now(FIXED_TIME)) + .when(clock) + .instant(); + } + + @BeforeEach + void setJavaMailSender() { + doReturn(mock(MimeMessage.class)) + .when(javaMailSender).createMimeMessage(); + doNothing() + .when(javaMailSender).send(any(MimeMessage.class)); + } +} diff --git a/backend/src/test/java/com/cruru/util/DbCleaner.java b/backend/src/test/java/com/cruru/util/DbCleaner.java new file mode 100644 index 000000000..99a01ff19 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/DbCleaner.java @@ -0,0 +1,75 @@ +package com.cruru.util; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.metamodel.EntityType; +import jakarta.transaction.Transactional; +import java.util.List; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile("test") +public class DbCleaner { + + private static final String INTEGRITY_FALSE = "SET REFERENTIAL_INTEGRITY FALSE"; + private static final String INTEGRITY_TRUE = "SET REFERENTIAL_INTEGRITY TRUE"; + private static final String CAMEL_CASE = "([a-z])([A-Z])"; + private static final String SNAKE_CASE = "$1_$2"; + private static final String TRUNCATE_TABLE = "TRUNCATE TABLE %s"; + private static final String RESET_ID_SEQUENCE = "ALTER TABLE %s ALTER COLUMN %s_id RESTART WITH 1"; + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @PostConstruct + public void findTableNames() { + tableNames = entityManager.getMetamodel().getEntities() + .stream() + .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null) + .map(this::convertCamelToSnake) + .toList(); + } + + private String convertCamelToSnake(final EntityType e) { + return e.getName() + .replaceAll(CAMEL_CASE, SNAKE_CASE) + .toLowerCase(); + } + + @Transactional + public void truncateEveryTable() { + entityManager.clear(); + disableIntegrity(); + + for (String tableName : tableNames) { + truncateTable(tableName); + resetIdColumn(tableName); + } + enableIntegrity(); + } + + private void disableIntegrity() { + entityManager.createNativeQuery(INTEGRITY_FALSE) + .executeUpdate(); + } + + private void truncateTable(final String tableName) { + entityManager.createNativeQuery(String.format(TRUNCATE_TABLE, tableName)) + .executeUpdate(); + } + + private void resetIdColumn(final String tableName) { + entityManager.createNativeQuery(String.format(RESET_ID_SEQUENCE, tableName, tableName)) + .executeUpdate(); + } + + private void enableIntegrity() { + entityManager.createNativeQuery(INTEGRITY_TRUE) + .executeUpdate(); + } +} diff --git a/backend/src/test/java/com/cruru/util/RepositoryTest.java b/backend/src/test/java/com/cruru/util/RepositoryTest.java new file mode 100644 index 000000000..17909d595 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/RepositoryTest.java @@ -0,0 +1,9 @@ +package com.cruru.util; + +import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@DataJpaTest(excludeAutoConfiguration = {FlywayAutoConfiguration.class}) +public class RepositoryTest { + +} diff --git a/backend/src/test/java/com/cruru/util/ServiceTest.java b/backend/src/test/java/com/cruru/util/ServiceTest.java new file mode 100644 index 000000000..2d7f16e12 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/ServiceTest.java @@ -0,0 +1,77 @@ +package com.cruru.util; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import com.cruru.club.domain.Club; +import com.cruru.club.domain.repository.ClubRepository; +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.dashboard.domain.repository.DashboardRepository; +import com.cruru.global.LoginProfile; +import com.cruru.member.domain.Member; +import com.cruru.member.domain.repository.MemberRepository; +import com.cruru.util.fixture.ClubFixture; +import com.cruru.util.fixture.DashboardFixture; +import com.cruru.util.fixture.LocalDateFixture; +import com.cruru.util.fixture.MemberFixture; +import jakarta.mail.internet.MimeMessage; +import java.time.Clock; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +@ActiveProfiles("test") +public class ServiceTest { + + private static final Clock FIXED_TIME = LocalDateFixture.fixedClock(); + + protected Member defaultMember; + protected Club defaultClub; + protected Dashboard defaultDashboard; + protected LoginProfile loginProfile; + @MockBean + protected JavaMailSender javaMailSender; + @Autowired + private DbCleaner dbCleaner; + @Autowired + private MemberRepository memberRepository; + @Autowired + private ClubRepository clubRepository; + @Autowired + private DashboardRepository dashboardRepository; + @SpyBean + private Clock clock; + + @BeforeEach + void resetDb() { + dbCleaner.truncateEveryTable(); + defaultMember = memberRepository.save(MemberFixture.ADMIN); + defaultClub = clubRepository.save(ClubFixture.create(defaultMember)); + defaultDashboard = dashboardRepository.save(DashboardFixture.backend(defaultClub)); + loginProfile = new LoginProfile(defaultMember.getEmail(), defaultMember.getRole()); + } + + @BeforeEach + void setClock() { + doReturn(Instant.now(FIXED_TIME)) + .when(clock) + .instant(); + } + + @BeforeEach + void setJavaMailSender() { + doReturn(mock(MimeMessage.class)) + .when(javaMailSender).createMimeMessage(); + doNothing() + .when(javaMailSender).send(any(MimeMessage.class)); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/AnswerFixture.java b/backend/src/test/java/com/cruru/util/fixture/AnswerFixture.java new file mode 100644 index 000000000..c3700b21e --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/AnswerFixture.java @@ -0,0 +1,20 @@ +package com.cruru.util.fixture; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.question.domain.Answer; +import com.cruru.question.domain.Question; + +public class AnswerFixture { + + public static Answer first(Question question, Applicant applicant) { + return new Answer("응답1", question, applicant); + } + + public static Answer second(Question question, Applicant applicant) { + return new Answer("응답2", question, applicant); + } + + public static Answer simple(Question question, Applicant applicant) { + return new Answer("단답", question, applicant); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/ApplicantFixture.java b/backend/src/test/java/com/cruru/util/fixture/ApplicantFixture.java new file mode 100644 index 000000000..eb1ac7b57 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/ApplicantFixture.java @@ -0,0 +1,35 @@ +package com.cruru.util.fixture; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.process.domain.Process; + +public class ApplicantFixture { + + public static Applicant pendingDobby() { + return new Applicant("도비", "DOBBY@email.com", "01000000000", null); + } + + public static Applicant pendingDobby(Process process) { + return new Applicant("도비", "DOBBY@email.com", "01000000000", process); + } + + public static Applicant pendingRush() { + return new Applicant("러쉬", "RUSH@email.com", "01000000001", null); + } + + public static Applicant pendingRush(Process process) { + return new Applicant("러쉬", "RUSH@email.com", "01000000001", process); + } + + public static Applicant rejectedRush() { + Applicant applicant = new Applicant("러쉬", "RUSH@email.com", "01000000001", null); + applicant.reject(); + return applicant; + } + + public static Applicant rejectedRush(Process process) { + Applicant applicant = new Applicant("러쉬", "rush@email.com", "01000000001", process); + applicant.reject(); + return applicant; + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/ApplyFormFixture.java b/backend/src/test/java/com/cruru/util/fixture/ApplyFormFixture.java new file mode 100644 index 000000000..74a96ee68 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/ApplyFormFixture.java @@ -0,0 +1,47 @@ +package com.cruru.util.fixture; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.dashboard.domain.Dashboard; + +public class ApplyFormFixture { + + public static ApplyForm backend(Dashboard backendDashboard) { + return new ApplyForm( + "크루루 백엔드 모집 공고", + "# 모집공고 설명1 ## 이렇게 지원하세요", + LocalDateFixture.oneDayAgo(), + LocalDateFixture.oneWeekLater(), + backendDashboard + ); + } + + public static ApplyForm backend() { + return new ApplyForm( + "크루루 백엔드 모집 공고", + "# 모집공고 설명1 ## 이렇게 지원하세요", + LocalDateFixture.oneDayAgo(), + LocalDateFixture.oneWeekLater(), + null + ); + } + + public static ApplyForm frontend(Dashboard frontendDashboard) { + return new ApplyForm( + "크루루 프론트엔드 모집 공고", + "# 모집공고 설명2 ## 이렇게 지원하세요", + LocalDateFixture.oneDayAgo(), + LocalDateFixture.oneWeekLater(), + frontendDashboard + ); + } + + public static ApplyForm notStarted() { + return new ApplyForm( + "크루루 프론트엔드 모집 공고", + "# 모집공고 설명2 ## 이렇게 지원하세요", + LocalDateFixture.oneDayLater(), + LocalDateFixture.oneWeekLater(), + null + ); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/ChoiceFixture.java b/backend/src/test/java/com/cruru/util/fixture/ChoiceFixture.java new file mode 100644 index 000000000..cddc60808 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/ChoiceFixture.java @@ -0,0 +1,38 @@ +package com.cruru.util.fixture; + +import com.cruru.question.domain.Choice; +import com.cruru.question.domain.Question; +import java.util.List; + +public class ChoiceFixture { + + public static List fiveChoices(Question question) { + return List.of( + first(question), + second(question), + third(question), + fourth(question), + fifth(question) + ); + } + + public static Choice first(Question question) { + return new Choice("1번 선택지", 1, question); + } + + public static Choice second(Question question) { + return new Choice("2번 선택지", 2, question); + } + + public static Choice third(Question question) { + return new Choice("3번 선택지", 3, question); + } + + public static Choice fourth(Question question) { + return new Choice("4번 선택지", 4, question); + } + + public static Choice fifth(Question question) { + return new Choice("5번 선택지", 5, question); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/ClubFixture.java b/backend/src/test/java/com/cruru/util/fixture/ClubFixture.java new file mode 100644 index 000000000..12973b1d0 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/ClubFixture.java @@ -0,0 +1,15 @@ +package com.cruru.util.fixture; + +import com.cruru.club.domain.Club; +import com.cruru.member.domain.Member; + +public class ClubFixture { + + public static Club create() { + return new Club("크루루", null); + } + + public static Club create(Member member) { + return new Club("크루루", member); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/DashboardFixture.java b/backend/src/test/java/com/cruru/util/fixture/DashboardFixture.java new file mode 100644 index 000000000..3ce5e315d --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/DashboardFixture.java @@ -0,0 +1,23 @@ +package com.cruru.util.fixture; + +import com.cruru.club.domain.Club; +import com.cruru.dashboard.domain.Dashboard; + +public class DashboardFixture { + + public static Dashboard backend() { + return new Dashboard(null); + } + + public static Dashboard backend(Club club) { + return new Dashboard(club); + } + + public static Dashboard frontend() { + return new Dashboard(null); + } + + public static Dashboard frontend(Club club) { + return new Dashboard(club); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/EmailFixture.java b/backend/src/test/java/com/cruru/util/fixture/EmailFixture.java new file mode 100644 index 000000000..bdfb76071 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/EmailFixture.java @@ -0,0 +1,35 @@ +package com.cruru.util.fixture; + +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.email.domain.Email; + +public class EmailFixture { + + public static final String SUBJECT = "[우아한테크코스] 7기 최종 심사 결과 안내"; + public static final String APPROVE_CONTENT = "우아한테크코스 합격을 진심으로 축하합니다!"; + public static final String REJECT_CONTENT = "지원해주셔서 감사합니다. 불합격입니다."; + + public static Dashboard backend() { + return new Dashboard(null); + } + + public static Email approveEmail() { + return new Email( + null, + null, + SUBJECT, + APPROVE_CONTENT, + true + ); + } + + public static Email rejectEmail() { + return new Email( + null, + null, + SUBJECT, + REJECT_CONTENT, + true + ); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/EvaluationFixture.java b/backend/src/test/java/com/cruru/util/fixture/EvaluationFixture.java new file mode 100644 index 000000000..d642d6ae3 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/EvaluationFixture.java @@ -0,0 +1,24 @@ +package com.cruru.util.fixture; + +import com.cruru.applicant.domain.Applicant; +import com.cruru.applicant.domain.Evaluation; +import com.cruru.process.domain.Process; + +public class EvaluationFixture { + + public static Evaluation fivePoints() { + return new Evaluation(5, "서류가 인상 깊었습니다.", null, null); + } + + public static Evaluation fivePoints(Process process, Applicant applicant) { + return new Evaluation(5, "서류가 인상 깊었습니다.", process, applicant); + } + + public static Evaluation fourPoints() { + return new Evaluation(4, "포트폴리오가 인상 깊었습니다.", null, null); + } + + public static Evaluation fourPoints(Process process, Applicant applicant) { + return new Evaluation(4, "포트폴리오가 인상 깊었습니다.", process, applicant); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/LocalDateFixture.java b/backend/src/test/java/com/cruru/util/fixture/LocalDateFixture.java new file mode 100644 index 000000000..dc68fb693 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/LocalDateFixture.java @@ -0,0 +1,29 @@ +package com.cruru.util.fixture; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +public class LocalDateFixture { + + public static LocalDateTime oneDayAgo() { + return LocalDateTime.now(fixedClock()).minusDays(1); + } + + public static Clock fixedClock() { + return Clock.fixed(Instant.parse("2024-08-01T02:00:00Z"), ZoneOffset.UTC); + } + + public static LocalDateTime oneWeekAgo() { + return LocalDateTime.now(fixedClock()).minusWeeks(1); + } + + public static LocalDateTime oneDayLater() { + return LocalDateTime.now(fixedClock()).plusDays(1); + } + + public static LocalDateTime oneWeekLater() { + return LocalDateTime.now(fixedClock()).plusWeeks(1); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/MemberFixture.java b/backend/src/test/java/com/cruru/util/fixture/MemberFixture.java new file mode 100644 index 000000000..c44ae609b --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/MemberFixture.java @@ -0,0 +1,17 @@ +package com.cruru.util.fixture; + +import com.cruru.member.domain.Member; +import com.cruru.member.domain.MemberRole; + +public class MemberFixture { + + public static final Member ADMIN = new Member(1000000L, "admin@email.com", + "$2a$10$rG0JsflKdGcORjGFTURYb.npEgtvClK4.3P.EMr/o3SdekrVFxOvG", "01011111111", + MemberRole.ADMIN); // password 원문: qwer1234 + public static final Member DOBBY = new Member("dobby@email.com", + "$2a$10$RKBwn5Sa7EO0lYaZqU1zSupNbPJ5/HOKcI7gNb9c2q.TiydBHUBQK", + "01011111111"); // password 원문: newPassword214! + public static final Member RUSH = new Member("rush@email.com", + "$2a$10$RKBwn5Sa7EO0lYaZqU1zSupNbPJ5/HOKcI7gNb9c2q.TiydBHUBQK", + "01022222222"); // password 원문: newPassword214! +} diff --git a/backend/src/test/java/com/cruru/util/fixture/ProcessFixture.java b/backend/src/test/java/com/cruru/util/fixture/ProcessFixture.java new file mode 100644 index 000000000..0c4356c54 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/ProcessFixture.java @@ -0,0 +1,39 @@ +package com.cruru.util.fixture; + +import com.cruru.dashboard.domain.Dashboard; +import com.cruru.process.domain.Process; +import com.cruru.process.domain.ProcessType; +import java.util.List; + +public class ProcessFixture { + + public static List maxSizeOf(Dashboard dashboard) { + return List.of( + applyType(dashboard), + new Process(1, "코딩 테스트", "온라인", ProcessType.EVALUATE, dashboard), + new Process(2, "CS 테스트", "온라인", ProcessType.EVALUATE, dashboard), + new Process(3, "1차 면접", "화상 면접", ProcessType.EVALUATE, dashboard), + approveType(dashboard) + ); + } + + public static Process applyType(Dashboard dashboard) { + return new Process(0, "지원 접수", "지원자가 지원서를 제출하는 단계", ProcessType.APPLY, dashboard); + } + + public static Process approveType(Dashboard dashboard) { + return new Process(1, "최종 합격", "지원자가 최종적으로 합격한 단계", ProcessType.APPROVE, dashboard); + } + + public static Process applyType() { + return new Process(0, "지원 접수", "지원자가 지원서를 제출하는 단계", ProcessType.APPLY, null); + } + + public static Process approveType() { + return new Process(1, "최종 합격", "지원자가 최종적으로 합격한 단계", ProcessType.APPROVE, null); + } + + public static Process interview(Dashboard dashboard) { + return new Process(1, "1차 면접", "화상 면접", ProcessType.EVALUATE, dashboard); + } +} diff --git a/backend/src/test/java/com/cruru/util/fixture/QuestionFixture.java b/backend/src/test/java/com/cruru/util/fixture/QuestionFixture.java new file mode 100644 index 000000000..1541e7dc6 --- /dev/null +++ b/backend/src/test/java/com/cruru/util/fixture/QuestionFixture.java @@ -0,0 +1,55 @@ +package com.cruru.util.fixture; + +import static com.cruru.question.domain.QuestionType.DROPDOWN; +import static com.cruru.question.domain.QuestionType.LONG_ANSWER; +import static com.cruru.question.domain.QuestionType.MULTIPLE_CHOICE; +import static com.cruru.question.domain.QuestionType.SHORT_ANSWER; +import static com.cruru.question.domain.QuestionType.SINGLE_CHOICE; + +import com.cruru.applyform.domain.ApplyForm; +import com.cruru.question.domain.Question; +import java.util.List; + +public class QuestionFixture { + + public static List allTypes(ApplyForm applyForm) { + return List.of( + shortAnswerType(applyForm), + longAnswerType(applyForm), + dropdownType(applyForm), + multipleChoiceType(applyForm), + singleChoiceType(applyForm) + ); + } + + public static Question shortAnswerType(ApplyForm applyForm) { + return new Question(SHORT_ANSWER, "주관식 단답형", 1, false, applyForm); + } + + public static Question longAnswerType(ApplyForm applyForm) { + return new Question(LONG_ANSWER, "주관식 장문형", 2, false, applyForm); + } + + public static Question dropdownType(ApplyForm applyForm) { + return new Question(DROPDOWN, "객관식 단일 선택", 3, false, applyForm); + } + + public static Question multipleChoiceType(ApplyForm applyForm) { + return new Question(MULTIPLE_CHOICE, "객관식 다중 선택", 4, false, applyForm); + } + + public static Question singleChoiceType(ApplyForm applyForm) { + return new Question(SINGLE_CHOICE, "객관식 단일 선택", 5, false, applyForm); + } + + public static List nonChoiceType(ApplyForm applyForm) { + return List.of( + shortAnswerType(applyForm), + longAnswerType(applyForm) + ); + } + + public static Question required(ApplyForm applyForm) { + return new Question(SHORT_ANSWER, "주관식 단답형", 1, true, applyForm); + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml new file mode 100644 index 000000000..55cce93a4 --- /dev/null +++ b/backend/src/test/resources/application.yml @@ -0,0 +1,49 @@ +spring: + config: + activate: + on-profile: test + h2: + console: + enabled: true + datasource: + read: + jdbc-url: jdbc:h2:mem:database;MODE=MySQL; + write: + jdbc-url: jdbc:h2:mem:database;MODE=MySQL; + flyway: + enabled: false + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: create-drop + defer-datasource-initialization: false + mail: + host: smtp.gmail.com + +dataloader: + enable: false + +security: + jwt: + token: + secret-key: test + expire-length: 1209600000 + algorithm: HS256 + +cookie: + access-token-key: token + http-only: false + secure: false + domain: localhost + path: / + same-site: none + max-age: 7200 #2시간 + +management: + health: + mail: + enabled: false diff --git a/backend/src/test/resources/static/email_test.txt b/backend/src/test/resources/static/email_test.txt new file mode 100644 index 000000000..f13dc8520 --- /dev/null +++ b/backend/src/test/resources/static/email_test.txt @@ -0,0 +1 @@ +이메일 발송 테스트용 파일