diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md index 118381ac..30e91e19 100644 --- a/.github/ISSUE_TEMPLATE/issue-template.md +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -7,11 +7,11 @@ assignees: '' --- -## πŸ“‹ μΆ”κ°€ κΈ°λŠ₯ +## πŸ“‹ 이슈 λ‚΄μš© -[μΆ”κ°€ν•  κΈ°λŠ₯ μž‘μ„±] +[이슈 λ‚΄μš© μž‘μ„±] -## πŸ› οΈ μž‘μ—… λ‚΄μš© +## πŸ› οΈ μž‘μ—…ν•  λ‚΄μš© - [ ] [TODO1] - [ ] [TODO2] diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index e648bde4..757f922b 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -1,6 +1,7 @@ name: cd dev on: + workflow_dispatch: pull_request: branches: [ "develop" ] types: [closed] @@ -18,7 +19,10 @@ jobs: with: token: ${{ secrets.SUBMODULE_TOKEN }} submodules: true - + + - name: Set timezone + run: echo "TZ=Asia/Seoul" >> $GITHUB_ENV + - name: Setup JDK 21 uses: actions/setup-java@v4 with: @@ -33,7 +37,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- @@ -49,7 +53,7 @@ jobs: run: ./gradlew bootJar - name: Copy jar file - run: mv ./build/libs/*SNAPSHOT.jar ./photopic-dev.jar + run: mv ./build/libs/*SNAPSHOT.jar ./chooz-dev.jar - name: (SCP) transfer build file uses: appleboy/scp-action@v0.1.7 @@ -57,7 +61,7 @@ jobs: host: ${{ secrets.AWS_EC2_URL_DEV }} username: ${{ secrets.AWS_EC2_USER }} key: ${{ secrets.AWS_EC2_KEY }} - source: photopic-dev.jar + source: chooz-dev.jar target: /home/${{ secrets.AWS_EC2_USER }} - name: (SSH) connect EC2 @@ -69,4 +73,4 @@ jobs: script_stop: true script: | sudo fuser -k -n tcp 8080 || true - nohup java -Xms256m -Xmx742m -jar -Dspring.profiles.active=dev photopic-dev.jar > ./output.log 2>&1 & + nohup java -Xms256m -Xmx742m -Dspring.profiles.active=dev -jar /home/ubuntu/chooz-dev.jar >> /home/ubuntu/output.log 2>&1 & diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 121f2a4e..4eac696f 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -18,7 +18,10 @@ jobs: with: token: ${{ secrets.SUBMODULE_TOKEN }} submodules: true - + + - name: Set timezone + run: echo "TZ=Asia/Seoul" >> $GITHUB_ENV + - name: Setup JDK 21 uses: actions/setup-java@v4 with: @@ -33,7 +36,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- @@ -49,7 +52,7 @@ jobs: run: ./gradlew bootJar - name: Copy jar file - run: mv ./build/libs/*SNAPSHOT.jar ./photopic-prod.jar + run: mv ./build/libs/*SNAPSHOT.jar ./chooz-prod.jar - name: (SCP) transfer build file uses: appleboy/scp-action@v0.1.7 @@ -57,7 +60,7 @@ jobs: host: ${{ secrets.AWS_EC2_URL_PROD }} username: ${{ secrets.AWS_EC2_USER }} key: ${{ secrets.AWS_EC2_KEY }} - source: photopic-prod.jar + source: chooz-prod.jar target: /home/${{ secrets.AWS_EC2_USER }} - name: (SSH) connect EC2 @@ -69,4 +72,4 @@ jobs: script_stop: true script: | sudo fuser -k -n tcp 8080 || true - nohup java -Xms256m -Xmx742m -jar -Dspring.profiles.active=prod photopic-prod.jar > ./output.log 2>&1 & + nohup java -Xms256m -Xmx742m -Dspring.profiles.active=prod -jar /home/ubuntu/chooz-prod.jar >> /home/ubuntu/output.log 2>&1 & diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d86d097c..4df2dea8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,10 @@ jobs: with: token: ${{ secrets.SUBMODULE_TOKEN }} submodules: true - + + - name: Set timezone + run: echo "TZ=Asia/Seoul" >> $GITHUB_ENV + - name: Setup JDK 21 uses: actions/setup-java@v4 with: @@ -32,9 +35,9 @@ jobs: uses: actions/cache@v4 with: path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | - ${{ runner.os }}-gradle- + ${{ runner.os }}-gradle- - name: Create directory resources run: mkdir -p ./src/test/resources diff --git a/.github/workflows/issue-to-trello.yml b/.github/workflows/issue-to-trello.yml new file mode 100644 index 00000000..9b46469b --- /dev/null +++ b/.github/workflows/issue-to-trello.yml @@ -0,0 +1,37 @@ +name: Create Trello Card on Issue + +on: + issues: + types: [opened] + +jobs: + create-trello-card: + runs-on: ubuntu-latest + + steps: + - name: Create Trello Card in Trello + env: + TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} + TRELLO_LABEL_ID: ${{ secrets.TRELLO_REVIEW_LABEL_ID }} + TRELLO_KEY: ${{ secrets.TRELLO_KEY }} + TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} + run: | + ISSUE_NUMBER="${{ github.event.issue.number }}" + ISSUE_TITLE="${{ github.event.issue.title }}" + ISSUE_BODY="${{ github.event.issue.body }}" + ISSUE_URL="${{ github.event.issue.html_url }}" + + DESCRIPTION=$(echo -e "Issue Number: #$ISSUE_NUMBER\n$ISSUE_URL\n\n$ISSUE_BODY" | jq -Rs .) + TITLE=$(echo "$ISSUE_TITLE" | jq -Rs .) + + curl --request POST \ + --url "https://api.trello.com/1/cards" \ + --header "Content-Type: application/json" \ + --data "{ + \"idList\": \"$TRELLO_LIST_ID\", + \"name\": $TITLE, + \"desc\": $DESCRIPTION, + \"idLabels\": \"$TRELLO_LABEL_ID\", + \"key\": \"$TRELLO_KEY\", + \"token\": \"$TRELLO_TOKEN\" + }" diff --git a/.github/workflows/update-trello.yml b/.github/workflows/update-trello.yml new file mode 100644 index 00000000..304dae37 --- /dev/null +++ b/.github/workflows/update-trello.yml @@ -0,0 +1,67 @@ +name: Update Trello Card on PR Events + +on: + pull_request: + types: [opened, closed] + +jobs: + update-trello-card: + runs-on: ubuntu-latest + + steps: + - name: Extract Issue Number from PR Body + id: extract + run: | + BODY="${{ github.event.pull_request.body }}" + ISSUE_NUMBER=$(echo "$BODY" | grep -oE "#[0-9]+" | head -n1 | tr -d '#') + echo "ISSUE_NUMBER=$ISSUE_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Skip if No Issue Number Found + if: steps.extract.outputs.ISSUE_NUMBER == '' + run: echo "No issue number found. Skipping workflow..." + + - name: Add PR Link as Comment to Trello Card + if: github.event.action == 'opened' && steps.extract.outputs.ISSUE_NUMBER != '' + env: + TRELLO_KEY: ${{ secrets.TRELLO_KEY }} + TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} + run: | + CARD_ID="${{ steps.extract.outputs.ISSUE_NUMBER }}" + PR_URL="${{ github.event.pull_request.html_url }}" + PR_COMMENT=$(echo "PR opened: $PR_URL" | jq -Rs .) + + curl --request POST \ + --url "https://api.trello.com/1/cards/$CARD_ID/actions/comments" \ + --header "Content-Type: application/json" \ + --data "{ + \"text\": $PR_COMMENT, + \"key\": \"$TRELLO_KEY\", + \"token\": \"$TRELLO_TOKEN\" + }" + + - name: Mark Card as Done when PR is Merged + if: github.event.action == 'closed' && github.event.pull_request.merged == true && steps.extract.outputs.ISSUE_NUMBER != '' + env: + TRELLO_KEY: ${{ secrets.TRELLO_KEY }} + TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} + TRELLO_DONE_LABEL_ID: ${{ secrets.TRELLO_DONE_LABEL_ID }} + run: | + CARD_ID="${{ steps.extract.outputs.ISSUE_NUMBER }}" + + curl --request POST \ + --url "https://api.trello.com/1/cards/$CARD_ID/idLabels" \ + --header "Content-Type: application/json" \ + --data "{ + \"value\": \"$TRELLO_DONE_LABEL_ID\", + \"key\": \"$TRELLO_KEY\", + \"token\": \"$TRELLO_TOKEN\" + }" + + curl --request PUT \ + --url "https://api.trello.com/1/cards/$CARD_ID" \ + --header "Content-Type: application/json" \ + --data "{ + \"dueComplete\": true, + \"key\": \"$TRELLO_KEY\", + \"token\": \"$TRELLO_TOKEN\" + }" diff --git a/.gitignore b/.gitignore index 5425fc8d..247d4d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ out/ application*.yml -/src/main/generated/ \ No newline at end of file +/src/main/generated/ +*.pem diff --git a/build.gradle b/build.gradle index 54b51a47..54d7b48f 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { id 'org.asciidoctor.jvm.convert' version '3.3.2' } -group = 'com.swyp8team2' +group = 'com.chooz' version = '0.0.1-SNAPSHOT' java { @@ -39,8 +39,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' // image - implementation 'software.amazon.awssdk:s3:2.30.18' - implementation 'software.amazon.awssdk:lambda:2.30.18' + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.4.0' implementation 'com.twelvemonkeys.imageio:imageio-webp:3.9.4' // gson @@ -49,11 +48,19 @@ dependencies { // base64 implementation 'io.seruco.encoding:base62:0.1.3' - compileOnly 'org.projectlombok:lombok' + implementation 'io.github.openfeign.querydsl:querydsl-jpa:6.10.1' + annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.10.1:jpa' + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' + + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + //test + testImplementation 'org.springframework.boot:spring-boot-docker-compose' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' @@ -93,3 +100,17 @@ tasks.named('test') { outputs.dir snippetsDir useJUnitPlatform() } + +def generated = 'src/main/generated' + +tasks.withType(JavaCompile).configureEach { + options.getGeneratedSourceOutputDirectory().set(file(generated)) +} + +sourceSets { + main.java.srcDirs += [ generated ] +} + +clean { + delete file(generated) +} diff --git a/server-config b/server-config index 6a4c9de0..ffce8ef1 160000 --- a/server-config +++ b/server-config @@ -1 +1 @@ -Subproject commit 6a4c9de01d1749b0ecbc96c32955aeb9b152713a +Subproject commit ffce8ef11e0fd7453570cb63fa0ada64d46b2c0d diff --git a/settings.gradle b/settings.gradle index 58682e9e..53f05a16 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'swyp8team2' +rootProject.name = 'chooz' diff --git a/src/docs/asciidoc/auth.adoc b/src/docs/asciidoc/auth.adoc index fcf60714..36edf858 100644 --- a/src/docs/asciidoc/auth.adoc +++ b/src/docs/asciidoc/auth.adoc @@ -6,8 +6,6 @@ operation::auth-controller-test/kakao-o-auth-sign-in[snippets='http-request,curl-request,request-fields,http-response,response-cookies,response-fields'] -[[게슀트-둜그인]] -=== `POST` 게슀트 둜그인 ``` 1. λ¦¬ν”„λ ˆμ‹œ 토큰이 μžˆλŠ” 경우 @@ -34,6 +32,6 @@ operation::auth-controller-test/reissue[snippets='http-request,curl-request,requ operation::auth-controller-test/sign-out[snippets='http-request,curl-request,request-cookies,request-headers,http-response,response-cookies'] [[νšŒμ›νƒˆν‡΄]] -=== `POST` νšŒμ›νƒˆν‡΄ (λ―Έκ΅¬ν˜„) +=== `POST` νšŒμ›νƒˆν‡΄ -operation::auth-controller-test/withdraw[snippets='http-request,curl-request,request-cookies,request-headers,http-response'] \ No newline at end of file +operation::auth-controller-test/withdraw[snippets='http-request,curl-request,request-headers,http-response'] \ No newline at end of file diff --git a/src/docs/asciidoc/comment-likes.adoc b/src/docs/asciidoc/comment-likes.adoc new file mode 100644 index 00000000..942384c2 --- /dev/null +++ b/src/docs/asciidoc/comment-likes.adoc @@ -0,0 +1,12 @@ +[[λŒ“κΈ€μ’‹μ•„μš”-API]] +== λŒ“κΈ€μ’‹μ•„μš” API + +[[λŒ“κΈ€μ’‹μ•„μš”-생성]] +=== `POST` λŒ“κΈ€ μ’‹μ•„μš” 생성 + +operation::comment-like-controller-test/create-comment-like[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] + +[[λŒ“κΈ€μ’‹μ•„μš”-μ‚­μ œ]] +=== `DELETE` λŒ“κΈ€ μ’‹μ•„μš” μ‚­μ œ + +operation::comment-like-controller-test/delete-comment-like[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] diff --git a/src/docs/asciidoc/comments.adoc b/src/docs/asciidoc/comments.adoc index 3282c5e6..0f103890 100644 --- a/src/docs/asciidoc/comments.adoc +++ b/src/docs/asciidoc/comments.adoc @@ -4,17 +4,17 @@ [[λŒ“κΈ€-생성]] === `POST` λŒ“κΈ€ 생성 -operation::comment-controller-test/create-comment[snippets='http-request,curl-request,path-parameters,request-headers,request-fields,http-response'] +operation::comment-controller-test/create-comments[snippets='http-request,curl-request,path-parameters,request-headers,request-fields,http-response,response-fields'] [[λŒ“κΈ€-쑰회]] === `GET` λŒ“κΈ€ 쑰회 -operation::comment-controller-test/find-comments[snippets='http-request,curl-request,path-parameters,http-response,response-fields'] +operation::comment-controller-test/find-comments[snippets='http-request,curl-request,path-parameters,request-headers,query-parameters,http-response,response-fields'] [[λŒ“κΈ€-μˆ˜μ •]] -=== `POST` λŒ“κΈ€ μˆ˜μ • +=== `PATCH` λŒ“κΈ€ μˆ˜μ • -operation::comment-controller-test/update-comment[snippets='http-request,curl-request,path-parameters,request-headers,request-fields,http-response'] +operation::comment-controller-test/update-comment[snippets='http-request,curl-request,path-parameters,request-headers,request-fields,http-response,response-fields'] [[λŒ“κΈ€-μ‚­μ œ]] === `DELETE` λŒ“κΈ€ μ‚­μ œ diff --git a/src/docs/asciidoc/images.adoc b/src/docs/asciidoc/images.adoc index 2b08f951..897062da 100644 --- a/src/docs/asciidoc/images.adoc +++ b/src/docs/asciidoc/images.adoc @@ -4,4 +4,4 @@ [[이미지-μ—…λ‘œλ“œ]] === `POST` 이미지 μ—…λ‘œλ“œ -operation::image-controller-test/create-image-file[snippets='http-request,curl-request,request-headers,request-parts,http-response,response-fields'] \ No newline at end of file +operation::image-controller-test/create-presigned-url[snippets='http-request,curl-request,request-headers,request-fields,http-response,response-fields'] \ No newline at end of file diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 91db8505..4c59269d 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -11,7 +11,7 @@ apiλ¬Έμ„œ == κ°œμš” ``` -λ½€λ˜ν”½ API λ¬Έμ„œμž…λ‹ˆλ‹€. +μΈ„μ¦ˆ API λ¬Έμ„œμž…λ‹ˆλ‹€. 잘λͺ»λ˜μ—ˆκ±°λ‚˜ μΆ”κ°€ 및 μˆ˜μ •λ˜μ–΄μ•Ό ν•  λ‚΄μš©μ΄ 있으면 μ–Έμ œλ“ μ§€ μ—°λ½μ£Όμ„Έμš”. ``` @@ -68,10 +68,7 @@ Authorization: Bearer accessToken ``` user1 -eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTc0MTA2MTc2NSwiaXNzIjoic3d5cDh0ZWFtMiIsImV4cCI6MzMyNzcwNjE3NjV9.3o2uNN3IuGZ-uLrAPdkHBBHF9kk9KALlP373eF27HI4 - -user2 -eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjIiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTc0MTA2MjkxMiwiaXNzIjoic3d5cDh0ZWFtMiIsImV4cCI6MzMyNzcwNjI5MTJ9.eC4oUp9ROb6udMarevZQcImTWojcL_3kkY1YgatpuJg +eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTc1MDA1ODc3OCwiaXNzIjoiY2hvb3otZGV2IiwiZXhwIjozMzI4NjA1ODc3OH0.STIaA5vs9g_zbaO5X7rj3zcnEqo1NLl6Iw_N5pcCeyc ``` [[인증-μ˜ˆμ™Έ]] @@ -110,4 +107,8 @@ include::posts.adoc[] include::votes.adoc[] -include::comments.adoc[] \ No newline at end of file +include::comments.adoc[] + +include::comment-likes.adoc[] + +include::notifications.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/notifications.adoc b/src/docs/asciidoc/notifications.adoc new file mode 100644 index 00000000..b078a802 --- /dev/null +++ b/src/docs/asciidoc/notifications.adoc @@ -0,0 +1,17 @@ +[[μ•Œλ¦Ό-API]] +== μ•Œλ¦Ό API + +[[μ•Œλ¦Ό-쑰회]] +=== `GET` μ•Œλ¦Ό 쑰회 + +operation::notification-controller-test/find-notifications[snippets='http-request,curl-request,path-parameters,request-headers,query-parameters,http-response,response-fields'] + +[[μ•Œλ¦Ό-읽기]] +=== `PATCH` μ•Œλ¦Ό 읽기 + +operation::notification-controller-test/mark-read[snippets='http-request,curl-request,path-parameters,request-headers'] + +[[μ•Œλ¦Ό-μƒνƒœ-확인]] +=== `GET` μ•Œλ¦Ό μƒνƒœ 확인 + +operation::notification-controller-test/present[snippets='http-request,curl-request,path-parameters,request-headers,http-response,response-fields'] diff --git a/src/docs/asciidoc/posts.adoc b/src/docs/asciidoc/posts.adoc index d288b5b1..a704caf3 100644 --- a/src/docs/asciidoc/posts.adoc +++ b/src/docs/asciidoc/posts.adoc @@ -9,6 +9,20 @@ operation::post-controller-test/create-post[snippets='http-request,curl-request, [[κ²Œμ‹œκΈ€-상세-쑰회]] === `GET` κ²Œμ‹œκΈ€ 상세 쑰회 +곡유 ν‚€ κ΄€λ ¨ +|=== +|κ³΅κ°œλ²”μœ„(scope)|μœ μ €|shareKey 일치 μ—¬λΆ€|응닡 μ—¬λΆ€ + +|PUBLIC|μž‘μ„±μž|O|O +|PUBLIC|μž‘μ„±μž|X|O +|PUBLIC|λ‹€λ₯Έ μ‚¬μš©μž|O|O +|PUBLIC|λ‹€λ₯Έ μ‚¬μš©μž|X|O +|PRIVATE|μž‘μ„±μž|O|O +|PRIVATE|μž‘μ„±μž|X|O +|PRIVATE|λ‹€λ₯Έ μ‚¬μš©μž|O|O +|PRIVATE|λ‹€λ₯Έ μ‚¬μš©μž|X|400μ—λŸ¬ +|=== + operation::post-controller-test/find-post[snippets='http-request,curl-request,path-parameters,http-response,response-fields'] [[κ°œμ‚¬κ΅΄-곡유-url-쑰회]] @@ -27,19 +41,19 @@ operation::post-controller-test/find-my-post[snippets='http-request,curl-request operation::post-controller-test/find-voted-post[snippets='http-request,curl-request,query-parameters,request-headers,http-response,response-fields'] [[κ²Œμ‹œκΈ€-νˆ¬ν‘œ-μˆ˜μ •]] -=== `POST` κ²Œμ‹œκΈ€ λ…ΈμΆœ λ³€κ²½ +=== `PUT` κ²Œμ‹œκΈ€ νˆ¬ν‘œ μˆ˜μ • -operation::post-controller-test/toggle-status-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] +operation::post-controller-test/update-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] [[κ²Œμ‹œκΈ€-νˆ¬ν‘œ-μˆ˜μ •]] -=== `POST` κ²Œμ‹œκΈ€ νˆ¬ν‘œ μˆ˜μ • (λ―Έκ΅¬ν˜„) +=== `GET` κ²Œμ‹œκΈ€ νˆ¬ν‘œ μˆ˜μ • 쑰회 -operation::post-controller-test/update-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] +operation::post-controller-test/find-post_update[snippets='path-parameters,http-response,response-fields'] [[κ²Œμ‹œκΈ€-νˆ¬ν‘œ-마감]] === `POST` κ²Œμ‹œκΈ€ νˆ¬ν‘œ 마감 -operation::post-controller-test/close-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] +operation::post-controller-test/close-by-author-post[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] [[κ²Œμ‹œκΈ€-μ‚­μ œ]] === `DELETE` κ²Œμ‹œκΈ€ μ‚­μ œ diff --git a/src/docs/asciidoc/users.adoc b/src/docs/asciidoc/users.adoc index ae247c22..27aa6352 100644 --- a/src/docs/asciidoc/users.adoc +++ b/src/docs/asciidoc/users.adoc @@ -7,6 +7,18 @@ operation::user-controller-test/find-user-info[snippets='http-request,curl-request,path-parameters,http-response,response-fields'] [[본인-정보-쑰회]] -=== `GET` 본인 정보 쑰회 +=== `GET` λ‚΄ 정보 쑰회 operation::user-controller-test/find-me[snippets='http-request,curl-request,request-headers,http-response,response-fields'] + +[[본인-정보-μˆ˜μ •]] +=== `PUT` λ‚΄ 정보 μˆ˜μ • + +operation::user-controller-test/update-me[snippets='http-request,curl-request,request-headers,request-fields,http-response'] + +[[μ˜¨λ³΄λ”©-단계-μ™„λ£Œ]] +=== `PATCH` μ˜¨λ³΄λ”© 단계 μ™„λ£Œ + +operation::user-controller-test/complete-step[snippets='http-request,curl-request,request-headers,request-fields,http-response,response-fields'] + + diff --git a/src/docs/asciidoc/votes.adoc b/src/docs/asciidoc/votes.adoc index 98fb98bd..206c3093 100644 --- a/src/docs/asciidoc/votes.adoc +++ b/src/docs/asciidoc/votes.adoc @@ -4,14 +4,13 @@ [[νˆ¬ν‘œ-ν˜„ν™©-쑰회]] === `GET` νˆ¬ν‘œ ν˜„ν™© 쑰회 -operation::vote-controller-test/find-vote-status[snippets='http-request,curl-request,request-headers,path-parameters,http-response,response-fields'] +operation::vote-controller-test/find-vote-result[snippets='http-request,curl-request,request-headers,path-parameters,http-response,response-fields'] [[νˆ¬ν‘œ]] === `POST` νˆ¬ν‘œ -operation::vote-controller-test/vote[snippets='http-request,curl-request,request-headers,request-fields,http-response'] - -[[νˆ¬ν‘œ-μ·¨μ†Œ]] -=== `DELETE` νˆ¬ν‘œ μ·¨μ†Œ +``` +ν•΄λ‹Ή API둜 νˆ¬ν‘œ μ·¨μ†Œ, μˆ˜μ • 같이 μ‚¬μš© +``` -operation::vote-controller-test/cancel-vote[snippets='http-request,curl-request,path-parameters,request-headers,http-response'] +operation::vote-controller-test/vote[snippets='http-request,curl-request,request-headers,request-fields,http-response'] diff --git a/src/main/java/com/swyp8team2/Swyp8team2Application.java b/src/main/java/com/chooz/ChoozApplication.java similarity index 61% rename from src/main/java/com/swyp8team2/Swyp8team2Application.java rename to src/main/java/com/chooz/ChoozApplication.java index 35563b89..45dc8e01 100644 --- a/src/main/java/com/swyp8team2/Swyp8team2Application.java +++ b/src/main/java/com/chooz/ChoozApplication.java @@ -1,13 +1,13 @@ -package com.swyp8team2; +package com.chooz; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class Swyp8team2Application { +public class ChoozApplication { public static void main(String[] args) { - SpringApplication.run(Swyp8team2Application.class, args); + SpringApplication.run(ChoozApplication.class, args); } } diff --git a/src/main/java/com/swyp8team2/auth/application/AuthService.java b/src/main/java/com/chooz/auth/application/AuthService.java similarity index 58% rename from src/main/java/com/swyp8team2/auth/application/AuthService.java rename to src/main/java/com/chooz/auth/application/AuthService.java index a015170a..742e8c7e 100644 --- a/src/main/java/com/swyp8team2/auth/application/AuthService.java +++ b/src/main/java/com/chooz/auth/application/AuthService.java @@ -1,23 +1,24 @@ -package com.swyp8team2.auth.application; +package com.chooz.auth.application; -import com.swyp8team2.auth.application.jwt.JwtClaim; -import com.swyp8team2.auth.application.jwt.JwtService; -import com.swyp8team2.auth.application.oauth.OAuthService; -import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; -import com.swyp8team2.auth.domain.Provider; -import com.swyp8team2.auth.domain.SocialAccount; -import com.swyp8team2.auth.domain.SocialAccountRepository; -import com.swyp8team2.auth.presentation.dto.TokenResponse; -import com.swyp8team2.user.application.UserService; -import com.swyp8team2.user.domain.Role; -import com.swyp8team2.user.domain.User; +import com.chooz.auth.application.jwt.JwtClaim; +import com.chooz.auth.application.jwt.JwtService; +import com.chooz.auth.application.oauth.OAuthService; +import com.chooz.auth.application.oauth.dto.OAuthUserInfo; +import com.chooz.auth.domain.Provider; +import com.chooz.auth.domain.SocialAccount; +import com.chooz.auth.domain.SocialAccountRepository; +import com.chooz.auth.presentation.dto.TokenResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.user.application.UserService; +import com.chooz.user.domain.Role; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Objects; - @Slf4j @Service @Transactional @@ -28,6 +29,7 @@ public class AuthService { private final OAuthService oAuthService; private final SocialAccountRepository socialAccountRepository; private final UserService userService; + private final UserRepository userRepository; public TokenResponse oauthSignIn(String code, String redirectUri) { OAuthUserInfo oAuthUserInfo = oAuthService.getUserInfo(code, redirectUri); @@ -56,19 +58,15 @@ public TokenResponse reissue(String refreshToken) { return response; } - public void signOut(Long userId, String refreshToken) { - jwtService.signOut(userId, refreshToken); + public void signOut(Long userId) { + jwtService.removeToken(userId); } - public TokenResponse guestSignIn(String refreshToken) { - TokenResponse response; - if (Objects.isNull(refreshToken)) { - User user = userService.createGuest(); - response = jwtService.createToken(new JwtClaim(user.getId(), user.getRole())); - } else { - response = jwtService.reissue(refreshToken); - } - log.debug("guestSignIn userId: {} tokenPair: {}", response.userId(), response.tokenPair()); - return response; + @Transactional + public void withdraw(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + jwtService.removeToken(userId); + userRepository.delete(user); } } diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java b/src/main/java/com/chooz/auth/application/jwt/JwtClaim.java similarity index 86% rename from src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java rename to src/main/java/com/chooz/auth/application/jwt/JwtClaim.java index 6ae7cc87..3700b1bb 100644 --- a/src/main/java/com/swyp8team2/auth/application/jwt/JwtClaim.java +++ b/src/main/java/com/chooz/auth/application/jwt/JwtClaim.java @@ -1,6 +1,6 @@ -package com.swyp8team2.auth.application.jwt; +package com.chooz.auth.application.jwt; -import com.swyp8team2.user.domain.Role; +import com.chooz.user.domain.Role; public class JwtClaim { diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/JwtProvider.java b/src/main/java/com/chooz/auth/application/jwt/JwtProvider.java similarity index 91% rename from src/main/java/com/swyp8team2/auth/application/jwt/JwtProvider.java rename to src/main/java/com/chooz/auth/application/jwt/JwtProvider.java index 96121e2f..edf376a9 100644 --- a/src/main/java/com/swyp8team2/auth/application/jwt/JwtProvider.java +++ b/src/main/java/com/chooz/auth/application/jwt/JwtProvider.java @@ -1,12 +1,11 @@ -package com.swyp8team2.auth.application.jwt; +package com.chooz.auth.application.jwt; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; -import com.swyp8team2.common.exception.UnauthorizedException; -import com.swyp8team2.user.domain.Role; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.InternalServerException; +import com.chooz.common.exception.UnauthorizedException; +import com.chooz.user.domain.Role; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java b/src/main/java/com/chooz/auth/application/jwt/JwtService.java similarity index 66% rename from src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java rename to src/main/java/com/chooz/auth/application/jwt/JwtService.java index 5c178d8d..9fc00460 100644 --- a/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java +++ b/src/main/java/com/chooz/auth/application/jwt/JwtService.java @@ -1,10 +1,10 @@ -package com.swyp8team2.auth.application.jwt; +package com.chooz.auth.application.jwt; -import com.swyp8team2.auth.domain.RefreshToken; -import com.swyp8team2.auth.domain.RefreshTokenRepository; -import com.swyp8team2.auth.presentation.dto.TokenResponse; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; +import com.chooz.auth.domain.RefreshToken; +import com.chooz.auth.domain.RefreshTokenRepository; +import com.chooz.auth.presentation.dto.TokenResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -42,13 +42,8 @@ public TokenResponse reissue(String refreshToken) { } @Transactional - public void signOut(Long userId, String refreshToken) { - RefreshToken token = refreshTokenRepository.findByUserId(userId) - .orElseThrow(() -> new BadRequestException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); - - if (!token.getToken().equals(refreshToken)) { - throw new BadRequestException(ErrorCode.REFRESH_TOKEN_MISMATCHED); - } - refreshTokenRepository.delete(token); + public void removeToken(Long userId) { + refreshTokenRepository.findByUserId(userId) + .ifPresent(refreshTokenRepository::delete); } } diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/TokenPair.java b/src/main/java/com/chooz/auth/application/jwt/TokenPair.java similarity index 66% rename from src/main/java/com/swyp8team2/auth/application/jwt/TokenPair.java rename to src/main/java/com/chooz/auth/application/jwt/TokenPair.java index e7a714ab..c2fd324b 100644 --- a/src/main/java/com/swyp8team2/auth/application/jwt/TokenPair.java +++ b/src/main/java/com/chooz/auth/application/jwt/TokenPair.java @@ -1,7 +1,7 @@ -package com.swyp8team2.auth.application.jwt; +package com.chooz.auth.application.jwt; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.InternalServerException; import java.util.Objects; diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/KakaoOAuthClient.java b/src/main/java/com/chooz/auth/application/oauth/KakaoOAuthClient.java similarity index 81% rename from src/main/java/com/swyp8team2/auth/application/oauth/KakaoOAuthClient.java rename to src/main/java/com/chooz/auth/application/oauth/KakaoOAuthClient.java index 17540f1a..990e2f7a 100644 --- a/src/main/java/com/swyp8team2/auth/application/oauth/KakaoOAuthClient.java +++ b/src/main/java/com/chooz/auth/application/oauth/KakaoOAuthClient.java @@ -1,7 +1,7 @@ -package com.swyp8team2.auth.application.oauth; +package com.chooz.auth.application.oauth; -import com.swyp8team2.auth.application.oauth.dto.KakaoAuthResponse; -import com.swyp8team2.auth.application.oauth.dto.KakaoUserInfoResponse; +import com.chooz.auth.application.oauth.dto.KakaoAuthResponse; +import com.chooz.auth.application.oauth.dto.KakaoUserInfoResponse; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/OAuthService.java b/src/main/java/com/chooz/auth/application/oauth/OAuthService.java similarity index 79% rename from src/main/java/com/swyp8team2/auth/application/oauth/OAuthService.java rename to src/main/java/com/chooz/auth/application/oauth/OAuthService.java index dd6516a4..a76d24b2 100644 --- a/src/main/java/com/swyp8team2/auth/application/oauth/OAuthService.java +++ b/src/main/java/com/chooz/auth/application/oauth/OAuthService.java @@ -1,11 +1,10 @@ -package com.swyp8team2.auth.application.oauth; +package com.chooz.auth.application.oauth; -import com.swyp8team2.auth.application.oauth.dto.KakaoAuthResponse; -import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; -import com.swyp8team2.common.config.KakaoOAuthConfig; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; +import com.chooz.auth.application.oauth.dto.KakaoAuthResponse; +import com.chooz.auth.application.oauth.dto.OAuthUserInfo; +import com.chooz.common.config.KakaoOAuthConfig; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoAuthResponse.java b/src/main/java/com/chooz/auth/application/oauth/dto/KakaoAuthResponse.java similarity index 83% rename from src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoAuthResponse.java rename to src/main/java/com/chooz/auth/application/oauth/dto/KakaoAuthResponse.java index dacc574c..fe4013f3 100644 --- a/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoAuthResponse.java +++ b/src/main/java/com/chooz/auth/application/oauth/dto/KakaoAuthResponse.java @@ -1,4 +1,4 @@ -package com.swyp8team2.auth.application.oauth.dto; +package com.chooz.auth.application.oauth.dto; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoUserInfoResponse.java b/src/main/java/com/chooz/auth/application/oauth/dto/KakaoUserInfoResponse.java similarity index 92% rename from src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoUserInfoResponse.java rename to src/main/java/com/chooz/auth/application/oauth/dto/KakaoUserInfoResponse.java index bae00bdb..bd52f34c 100644 --- a/src/main/java/com/swyp8team2/auth/application/oauth/dto/KakaoUserInfoResponse.java +++ b/src/main/java/com/chooz/auth/application/oauth/dto/KakaoUserInfoResponse.java @@ -1,8 +1,8 @@ -package com.swyp8team2.auth.application.oauth.dto; +package com.chooz.auth.application.oauth.dto; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import com.swyp8team2.auth.domain.Provider; +import com.chooz.auth.domain.Provider; import java.util.Objects; diff --git a/src/main/java/com/swyp8team2/auth/application/oauth/dto/OAuthUserInfo.java b/src/main/java/com/chooz/auth/application/oauth/dto/OAuthUserInfo.java similarity index 60% rename from src/main/java/com/swyp8team2/auth/application/oauth/dto/OAuthUserInfo.java rename to src/main/java/com/chooz/auth/application/oauth/dto/OAuthUserInfo.java index 8d987ab1..88eabda2 100644 --- a/src/main/java/com/swyp8team2/auth/application/oauth/dto/OAuthUserInfo.java +++ b/src/main/java/com/chooz/auth/application/oauth/dto/OAuthUserInfo.java @@ -1,6 +1,6 @@ -package com.swyp8team2.auth.application.oauth.dto; +package com.chooz.auth.application.oauth.dto; -import com.swyp8team2.auth.domain.Provider; +import com.chooz.auth.domain.Provider; public record OAuthUserInfo( String socialId, diff --git a/src/main/java/com/swyp8team2/auth/domain/Provider.java b/src/main/java/com/chooz/auth/domain/Provider.java similarity index 77% rename from src/main/java/com/swyp8team2/auth/domain/Provider.java rename to src/main/java/com/chooz/auth/domain/Provider.java index 6469c1c4..53dde05f 100644 --- a/src/main/java/com/swyp8team2/auth/domain/Provider.java +++ b/src/main/java/com/chooz/auth/domain/Provider.java @@ -1,7 +1,7 @@ -package com.swyp8team2.auth.domain; +package com.chooz.auth.domain; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.InternalServerException; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/swyp8team2/auth/domain/RefreshToken.java b/src/main/java/com/chooz/auth/domain/RefreshToken.java similarity index 84% rename from src/main/java/com/swyp8team2/auth/domain/RefreshToken.java rename to src/main/java/com/chooz/auth/domain/RefreshToken.java index 3f005c67..94f04980 100644 --- a/src/main/java/com/swyp8team2/auth/domain/RefreshToken.java +++ b/src/main/java/com/chooz/auth/domain/RefreshToken.java @@ -1,8 +1,8 @@ -package com.swyp8team2.auth.domain; +package com.chooz.auth.domain; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.util.Validator; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.util.Validator; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; diff --git a/src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java b/src/main/java/com/chooz/auth/domain/RefreshTokenRepository.java similarity index 88% rename from src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java rename to src/main/java/com/chooz/auth/domain/RefreshTokenRepository.java index 625a1c74..3b8cbc63 100644 --- a/src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java +++ b/src/main/java/com/chooz/auth/domain/RefreshTokenRepository.java @@ -1,4 +1,4 @@ -package com.swyp8team2.auth.domain; +package com.chooz.auth.domain; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/swyp8team2/auth/domain/SocialAccount.java b/src/main/java/com/chooz/auth/domain/SocialAccount.java similarity index 74% rename from src/main/java/com/swyp8team2/auth/domain/SocialAccount.java rename to src/main/java/com/chooz/auth/domain/SocialAccount.java index 3675e6c4..de9201cc 100644 --- a/src/main/java/com/swyp8team2/auth/domain/SocialAccount.java +++ b/src/main/java/com/chooz/auth/domain/SocialAccount.java @@ -1,7 +1,7 @@ -package com.swyp8team2.auth.domain; +package com.chooz.auth.domain; -import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; -import com.swyp8team2.common.domain.BaseEntity; +import com.chooz.auth.application.oauth.dto.OAuthUserInfo; +import com.chooz.common.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -9,11 +9,12 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import static com.swyp8team2.common.util.Validator.validateEmptyString; -import static com.swyp8team2.common.util.Validator.validateNull; +import static com.chooz.common.util.Validator.validateEmptyString; +import static com.chooz.common.util.Validator.validateNull; @Getter @Entity @@ -32,7 +33,8 @@ public class SocialAccount extends BaseEntity { @Enumerated(EnumType.STRING) private Provider provider; - public SocialAccount(Long id, Long userId, String socialId, Provider provider) { + @Builder + private SocialAccount(Long id, Long userId, String socialId, Provider provider) { validateNull(userId, provider); validateEmptyString(socialId); this.id = id; diff --git a/src/main/java/com/swyp8team2/auth/domain/SocialAccountRepository.java b/src/main/java/com/chooz/auth/domain/SocialAccountRepository.java similarity index 90% rename from src/main/java/com/swyp8team2/auth/domain/SocialAccountRepository.java rename to src/main/java/com/chooz/auth/domain/SocialAccountRepository.java index 4de3f26d..10f763f7 100644 --- a/src/main/java/com/swyp8team2/auth/domain/SocialAccountRepository.java +++ b/src/main/java/com/chooz/auth/domain/SocialAccountRepository.java @@ -1,4 +1,4 @@ -package com.swyp8team2.auth.domain; +package com.chooz.auth.domain; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/com/swyp8team2/auth/domain/UserInfo.java b/src/main/java/com/chooz/auth/domain/UserInfo.java similarity index 80% rename from src/main/java/com/swyp8team2/auth/domain/UserInfo.java rename to src/main/java/com/chooz/auth/domain/UserInfo.java index 84ac2c45..502de6dd 100644 --- a/src/main/java/com/swyp8team2/auth/domain/UserInfo.java +++ b/src/main/java/com/chooz/auth/domain/UserInfo.java @@ -1,15 +1,14 @@ -package com.swyp8team2.auth.domain; +package com.chooz.auth.domain; -import com.swyp8team2.user.domain.Role; +import com.chooz.user.domain.Role; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; -import java.util.Collections; import java.util.List; -import static com.swyp8team2.common.util.Validator.validateNull; +import static com.chooz.common.util.Validator.validateNull; public record UserInfo(long userId, Role role) implements UserDetails { diff --git a/src/main/java/com/swyp8team2/auth/presentation/AuthController.java b/src/main/java/com/chooz/auth/presentation/AuthController.java similarity index 64% rename from src/main/java/com/swyp8team2/auth/presentation/AuthController.java rename to src/main/java/com/chooz/auth/presentation/AuthController.java index 33493d43..69513258 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/AuthController.java +++ b/src/main/java/com/chooz/auth/presentation/AuthController.java @@ -1,15 +1,15 @@ -package com.swyp8team2.auth.presentation; +package com.chooz.auth.presentation; -import com.swyp8team2.auth.application.AuthService; -import com.swyp8team2.auth.application.jwt.TokenPair; -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.auth.presentation.dto.OAuthSignInRequest; -import com.swyp8team2.auth.presentation.dto.TokenResponse; -import com.swyp8team2.auth.presentation.dto.AuthResponse; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.presentation.CustomHeader; +import com.chooz.auth.application.AuthService; +import com.chooz.auth.application.jwt.TokenPair; +import com.chooz.auth.domain.UserInfo; +import com.chooz.auth.presentation.dto.AuthResponse; +import com.chooz.auth.presentation.dto.OAuthSignInRequest; +import com.chooz.auth.presentation.dto.TokenResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.presentation.CustomHeader; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -17,6 +17,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -44,18 +45,6 @@ public ResponseEntity kakaoOAuthSignIn( return ResponseEntity.ok(new AuthResponse(tokenPair.accessToken(), tokenResponse.userId(), tokenResponse.role())); } - @PostMapping("/guest/sign-in") - public ResponseEntity guestSignIn( - @CookieValue(name = CustomHeader.CustomCookie.REFRESH_TOKEN, required = false) String refreshToken, - HttpServletResponse response - ) { - TokenResponse tokenResponse = authService.guestSignIn(refreshToken); - TokenPair tokenPair = tokenResponse.tokenPair(); - Cookie cookie = refreshTokenCookieGenerator.createCookie(tokenPair.refreshToken()); - response.addCookie(cookie); - return ResponseEntity.ok(new AuthResponse(tokenPair.accessToken(), tokenResponse.userId(), tokenResponse.role())); - } - @PostMapping("/reissue") public ResponseEntity reissue( @CookieValue(name = CustomHeader.CustomCookie.REFRESH_TOKEN, required = false) String refreshToken, @@ -79,22 +68,21 @@ public ResponseEntity reissue( @PostMapping("/sign-out") public ResponseEntity signOut( - @CookieValue(name = CustomHeader.CustomCookie.REFRESH_TOKEN, required = false) String refreshToken, HttpServletResponse response, @AuthenticationPrincipal UserInfo userInfo ) { - if (Objects.isNull(refreshToken)) { - throw new BadRequestException(ErrorCode.INVALID_REFRESH_TOKEN_HEADER); - } refreshTokenCookieGenerator.removeCookie(response); - authService.signOut(userInfo.userId(), refreshToken); + authService.signOut(userInfo.userId()); return ResponseEntity.ok().build(); } - @PostMapping("/withdraw") + @DeleteMapping("/withdraw") public ResponseEntity withdraw( - @AuthenticationPrincipal UserInfo userInfo + @AuthenticationPrincipal UserInfo userInfo, + HttpServletResponse response ) { + refreshTokenCookieGenerator.removeCookie(response); + authService.withdraw(userInfo.userId()); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java b/src/main/java/com/chooz/auth/presentation/RefreshTokenCookieGenerator.java similarity index 74% rename from src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java rename to src/main/java/com/chooz/auth/presentation/RefreshTokenCookieGenerator.java index 825df15d..98641eca 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java +++ b/src/main/java/com/chooz/auth/presentation/RefreshTokenCookieGenerator.java @@ -1,6 +1,6 @@ -package com.swyp8team2.auth.presentation; +package com.chooz.auth.presentation; -import com.swyp8team2.common.presentation.CustomHeader; +import com.chooz.common.presentation.CustomHeader; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Value; @@ -14,13 +14,7 @@ public class RefreshTokenCookieGenerator { public Cookie createCookie(String refreshToken) { Cookie cookie = new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, refreshToken); - cookie.setHttpOnly(true); - if ("local".equals(activeProfile)) { - cookie.setSecure(false); - } else { - cookie.setSecure(true); - cookie.setAttribute("SameSite", "None"); - } + setCookieSecure(cookie); cookie.setPath("/"); cookie.setMaxAge(60 * 60 * 24 * 14); return cookie; @@ -28,16 +22,19 @@ public Cookie createCookie(String refreshToken) { public void removeCookie(HttpServletResponse response) { Cookie cookie = new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, null); + setCookieSecure(cookie); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + + private void setCookieSecure(Cookie cookie) { cookie.setHttpOnly(true); - cookie.setSecure(true); if ("local".equals(activeProfile)) { cookie.setSecure(false); } else { cookie.setSecure(true); cookie.setAttribute("SameSite", "None"); } - cookie.setPath("/"); - cookie.setMaxAge(0); - response.addCookie(cookie); } } diff --git a/src/main/java/com/chooz/auth/presentation/dto/AuthResponse.java b/src/main/java/com/chooz/auth/presentation/dto/AuthResponse.java new file mode 100644 index 00000000..9e87ee21 --- /dev/null +++ b/src/main/java/com/chooz/auth/presentation/dto/AuthResponse.java @@ -0,0 +1,4 @@ +package com.chooz.auth.presentation.dto; + +public record AuthResponse(String accessToken, Long userId, com.chooz.user.domain.Role role) { +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/GuestTokenResponse.java b/src/main/java/com/chooz/auth/presentation/dto/GuestTokenResponse.java similarity index 55% rename from src/main/java/com/swyp8team2/auth/presentation/dto/GuestTokenResponse.java rename to src/main/java/com/chooz/auth/presentation/dto/GuestTokenResponse.java index ee76d2f6..f9167b5b 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/dto/GuestTokenResponse.java +++ b/src/main/java/com/chooz/auth/presentation/dto/GuestTokenResponse.java @@ -1,4 +1,4 @@ -package com.swyp8team2.auth.presentation.dto; +package com.chooz.auth.presentation.dto; public record GuestTokenResponse(String guestToken) { } diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/OAuthSignInRequest.java b/src/main/java/com/chooz/auth/presentation/dto/OAuthSignInRequest.java similarity index 78% rename from src/main/java/com/swyp8team2/auth/presentation/dto/OAuthSignInRequest.java rename to src/main/java/com/chooz/auth/presentation/dto/OAuthSignInRequest.java index 7fa26ca1..724d6fe8 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/dto/OAuthSignInRequest.java +++ b/src/main/java/com/chooz/auth/presentation/dto/OAuthSignInRequest.java @@ -1,4 +1,4 @@ -package com.swyp8team2.auth.presentation.dto; +package com.chooz.auth.presentation.dto; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/chooz/auth/presentation/dto/TokenResponse.java b/src/main/java/com/chooz/auth/presentation/dto/TokenResponse.java new file mode 100644 index 00000000..1f7382a8 --- /dev/null +++ b/src/main/java/com/chooz/auth/presentation/dto/TokenResponse.java @@ -0,0 +1,10 @@ +package com.chooz.auth.presentation.dto; + +import com.chooz.auth.application.jwt.TokenPair; +import com.chooz.user.domain.Role; + +public record TokenResponse( + TokenPair tokenPair, + Long userId, + Role role +) { } diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/CustomAccessDenialHandler.java b/src/main/java/com/chooz/auth/presentation/filter/CustomAccessDenialHandler.java similarity index 90% rename from src/main/java/com/swyp8team2/auth/presentation/filter/CustomAccessDenialHandler.java rename to src/main/java/com/chooz/auth/presentation/filter/CustomAccessDenialHandler.java index c024d97c..cf9c659e 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/filter/CustomAccessDenialHandler.java +++ b/src/main/java/com/chooz/auth/presentation/filter/CustomAccessDenialHandler.java @@ -1,6 +1,6 @@ -package com.swyp8team2.auth.presentation.filter; +package com.chooz.auth.presentation.filter; -import com.swyp8team2.common.exception.ForbiddenException; +import com.chooz.common.exception.ForbiddenException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractor.java b/src/main/java/com/chooz/auth/presentation/filter/HeaderTokenExtractor.java similarity index 71% rename from src/main/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractor.java rename to src/main/java/com/chooz/auth/presentation/filter/HeaderTokenExtractor.java index 4bd616c7..7e2624ea 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractor.java +++ b/src/main/java/com/chooz/auth/presentation/filter/HeaderTokenExtractor.java @@ -1,7 +1,7 @@ -package com.swyp8team2.auth.presentation.filter; +package com.chooz.auth.presentation.filter; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.UnauthorizedException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.UnauthorizedException; import java.util.Objects; diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthFilter.java b/src/main/java/com/chooz/auth/presentation/filter/JwtAuthFilter.java similarity index 83% rename from src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthFilter.java rename to src/main/java/com/chooz/auth/presentation/filter/JwtAuthFilter.java index 5829e4b2..7a6a0cfd 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthFilter.java +++ b/src/main/java/com/chooz/auth/presentation/filter/JwtAuthFilter.java @@ -1,10 +1,9 @@ -package com.swyp8team2.auth.presentation.filter; +package com.chooz.auth.presentation.filter; -import com.swyp8team2.auth.application.jwt.JwtClaim; -import com.swyp8team2.auth.application.jwt.JwtProvider; -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.common.exception.ApplicationException; -import com.swyp8team2.user.domain.Role; +import com.chooz.auth.application.jwt.JwtClaim; +import com.chooz.auth.application.jwt.JwtProvider; +import com.chooz.auth.domain.UserInfo; +import com.chooz.common.exception.ApplicationException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -19,7 +18,7 @@ import java.io.IOException; -import static com.swyp8team2.auth.presentation.filter.JwtAuthenticationEntryPoint.EXCEPTION_KEY; +import static com.chooz.auth.presentation.filter.JwtAuthenticationEntryPoint.EXCEPTION_KEY; @Slf4j @RequiredArgsConstructor diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthenticationEntryPoint.java b/src/main/java/com/chooz/auth/presentation/filter/JwtAuthenticationEntryPoint.java similarity index 96% rename from src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthenticationEntryPoint.java rename to src/main/java/com/chooz/auth/presentation/filter/JwtAuthenticationEntryPoint.java index e39ca988..de6511a7 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/filter/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/chooz/auth/presentation/filter/JwtAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package com.swyp8team2.auth.presentation.filter; +package com.chooz.auth.presentation.filter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/src/main/java/com/chooz/comment/application/CommentCommandService.java b/src/main/java/com/chooz/comment/application/CommentCommandService.java new file mode 100644 index 00000000..3308bcb1 --- /dev/null +++ b/src/main/java/com/chooz/comment/application/CommentCommandService.java @@ -0,0 +1,63 @@ +package com.chooz.comment.application; + +import com.chooz.comment.domain.Comment; +import com.chooz.comment.domain.CommentRepository; +import com.chooz.comment.presentation.dto.CommentIdResponse; +import com.chooz.comment.presentation.dto.CommentRequest; +import com.chooz.comment.support.CommentValidator; +import com.chooz.commentLike.application.CommentLikeCommandService; +import com.chooz.common.event.DeleteEvent; +import com.chooz.common.event.EventPublisher; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.PostRepository; +import com.chooz.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class CommentCommandService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final CommentValidator commentValidator; + private final CommentLikeCommandService commentLikeCommandService; + private final EventPublisher eventPublisher; + + public CommentIdResponse createComment(Long postId, CommentRequest commentRequest, Long userId) { + commentValidator.validateContentLength(commentRequest.content()); + Comment commentForSave = Comment.create( + postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)).getId(), + userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)).getId(), + commentRequest.content() + ); + Comment commentFromSave = commentRepository.save(commentForSave); + return new CommentIdResponse(commentFromSave.getId()); + } + + public CommentIdResponse updateComment(Long postId, Long commentId, CommentRequest commentRequest, Long userId) { + commentValidator.validateContentLength(commentRequest.content()); + Comment commentForUpdate = commentRepository.findById(commentId) + .orElseThrow(() -> new BadRequestException(ErrorCode.COMMENT_NOT_FOUND)); + commentValidator.validateCommentAccess(commentForUpdate, postId, userId); + commentForUpdate.updateComment(commentRequest.content()); + return new CommentIdResponse(commentForUpdate.getId()); + } + + public void deleteComment(Long postId, Long commentId, Long userId) { + commentLikeCommandService.deleteCommentLikeByCommentId(commentId); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new BadRequestException(ErrorCode.COMMENT_NOT_FOUND)); + commentValidator.validateCommentAccess(comment, postId, userId); + commentRepository.delete(comment); + eventPublisher.publish(DeleteEvent.of(comment.getId(), comment.getClass().getSimpleName().toUpperCase())); + } +} diff --git a/src/main/java/com/chooz/comment/application/CommentQueryService.java b/src/main/java/com/chooz/comment/application/CommentQueryService.java new file mode 100644 index 00000000..30059725 --- /dev/null +++ b/src/main/java/com/chooz/comment/application/CommentQueryService.java @@ -0,0 +1,158 @@ +package com.chooz.comment.application; + +import com.chooz.comment.domain.Comment; +import com.chooz.comment.domain.CommentRepository; +import com.chooz.comment.presentation.dto.CommentAuthorDto; +import com.chooz.comment.presentation.dto.CommentDto; +import com.chooz.comment.presentation.dto.CommentLikeDto; +import com.chooz.comment.presentation.dto.CommentResponse; +import com.chooz.comment.support.CommentValidator; +import com.chooz.commentLike.domain.CommentLike; +import com.chooz.commentLike.domain.CommentLikeCountProjection; +import com.chooz.commentLike.domain.CommentLikeRepository; +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.PostRepository; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class CommentQueryService { + + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentValidator commentValidator; + + + public CommentResponse findComments(Long postId, Long userId, Long cursorId, int size) { + commentValidator.validateCommentActive(postRepository.findCommentActiveByPostId(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.CLOSE_COMMENT_ACTIVE))); + + Slice comments = commentRepository.findByPostId(postId, cursorId, PageRequest.ofSize(size)); + + + List commentIds = findCommentIds(comments); + List userIds = findUserIds(comments); + + int commentCount = commentRepository.countByPostId(postId); + Map likeCountCommentMap = findLikeCountCommentMap(commentIds); + List commentLikes = findCommentLikes(commentIds, userId); + Map likedCommentMap = findLikedCommentMap(commentLikes); + Map likedCommentLikeIdMap = findLikedCommentLikeIdMap(commentLikes); + Map userCommentMap = findUserCommentMap(userIds); + + List responseContent = + findResponseContent( + comments, + userCommentMap, + likedCommentLikeIdMap, + likedCommentMap, + likeCountCommentMap + ); + return new CommentResponse( + commentCount, + CursorBasePaginatedResponse.of( + new SliceImpl<>( + responseContent, + comments.getPageable(), + comments.hasNext() + ) + ) + ); + } + + private List findUserIds(Slice comments) { + return comments.getContent().stream() + .map(Comment::getUserId) + .distinct() + .toList(); + } + + private List findCommentIds(Slice comments) { + return comments.getContent().stream() + .map(Comment::getId) + .toList(); + } + + private User findUserByUserMap(Map userCommentMap, Comment comment) { + return Optional.ofNullable(userCommentMap.get(comment.getUserId())) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + } + + private Map findUserCommentMap(List userIds) { + return userRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getId, Function.identity())); + } + + private Map findLikeCountCommentMap(List commentIds) { + return commentLikeRepository.countByCommentIds(commentIds).stream() + .collect(Collectors.toMap( + CommentLikeCountProjection::getCommentId, + CommentLikeCountProjection::getLikeCount + )); + } + private List findCommentLikes(List commentIds, Long userId){ + return commentLikeRepository.findByCommentIdInAndUserId(commentIds, userId); + } + private Map findLikedCommentLikeIdMap(List commentLikes){ + return Optional.ofNullable(commentLikes) + .filter(list -> !list.isEmpty()) + .map(list -> list.stream() + .collect(Collectors.toMap( + CommentLike::getCommentId, + CommentLike::getId + )) + ).orElse(Collections.emptyMap()); + } + private Map findLikedCommentMap(List commentLikes) { + return Optional.ofNullable(commentLikes) + .filter(list -> !list.isEmpty()) + .map(list -> list.stream() + .collect(Collectors.toMap( + CommentLike::getCommentId, + cl -> true + )) + ).orElse(Collections.emptyMap()); + } + + private List findResponseContent(Slice comments, + Map userCommentMap, + Map likedCommentLikeIdMap, + Map likedCommentMap, + Map likeCountCommentMap + ) { + return comments.getContent().stream() + .map( comment -> { + return CommentDto.of( + comment, + CommentAuthorDto.of(findUserByUserMap(userCommentMap, comment)), + CommentLikeDto.of( + likedCommentLikeIdMap.getOrDefault(comment.getId(), null), + likedCommentMap.getOrDefault(comment.getId(), false), + likeCountCommentMap.getOrDefault(comment.getId(), 0L).intValue() + ) + ); + } + ).toList(); + } +} diff --git a/src/main/java/com/chooz/comment/application/CommentService.java b/src/main/java/com/chooz/comment/application/CommentService.java new file mode 100644 index 00000000..c06c54eb --- /dev/null +++ b/src/main/java/com/chooz/comment/application/CommentService.java @@ -0,0 +1,39 @@ +package com.chooz.comment.application; + +import com.chooz.comment.presentation.dto.CommentIdResponse; +import com.chooz.comment.presentation.dto.CommentRequest; +import com.chooz.comment.presentation.dto.CommentResponse; +import com.chooz.common.dto.CursorBasePaginatedResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class CommentService { + + private final CommentQueryService commentQueryService; + private final CommentCommandService commentCommandService; + + public CommentResponse findComments(Long postId, Long userId, Long cursorId, int size) { + return commentQueryService.findComments(postId, userId, cursorId, size); + } + + @Transactional + public CommentIdResponse createComment(Long postId, CommentRequest commentRequest, Long userId) { + return commentCommandService.createComment(postId, commentRequest, userId); + } + + @Transactional + public CommentIdResponse updateComment(Long postId, Long commentId, CommentRequest commentRequest, Long userId) { + return commentCommandService.updateComment(postId, commentId, commentRequest, userId); + } + + @Transactional + public void deleteComment(Long postId, Long commentId, Long userId) { + commentCommandService.deleteComment(postId, commentId, userId); + } +} diff --git a/src/main/java/com/chooz/comment/domain/Comment.java b/src/main/java/com/chooz/comment/domain/Comment.java new file mode 100644 index 00000000..a0ed755f --- /dev/null +++ b/src/main/java/com/chooz/comment/domain/Comment.java @@ -0,0 +1,53 @@ +package com.chooz.comment.domain; + +import com.chooz.common.domain.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import static com.chooz.common.util.Validator.validateEmptyString; +import static com.chooz.common.util.Validator.validateNull; + +@Entity +@Getter +@Table(name = "comments") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Comment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long postId; + + @NotNull + private Long userId; + + @NotNull + private String content; + + @NotNull + private Boolean edited; + + public Comment(Long postId, Long userId, String content) { + validateNull(postId, userId); + validateEmptyString(content); + this.postId = postId; + this.userId = userId; + this.content = content; + this.edited = false; + } + + public static Comment create(Long postId, Long userId, String content) { + return new Comment(null, postId, userId, content, false); + } + + public void updateComment(String content) { + validateEmptyString(content); + this.content = content; + this.edited = true; + } +} diff --git a/src/main/java/com/chooz/comment/domain/CommentRepository.java b/src/main/java/com/chooz/comment/domain/CommentRepository.java new file mode 100644 index 00000000..500edb2e --- /dev/null +++ b/src/main/java/com/chooz/comment/domain/CommentRepository.java @@ -0,0 +1,34 @@ +package com.chooz.comment.domain; + +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + + @Query(""" + SELECT c + FROM Comment c + WHERE c.postId = :postId + AND (:cursorId is null OR c.id < :cursorId) + ORDER BY + c.id DESC + """) + Slice findByPostId( + @Param("postId") Long postId, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + + int countByPostId(@NotNull Long postId); + + List findByPostIdAndDeletedFalse(@NotNull Long postId); + +} diff --git a/src/main/java/com/chooz/comment/presentation/CommentController.java b/src/main/java/com/chooz/comment/presentation/CommentController.java new file mode 100644 index 00000000..20625271 --- /dev/null +++ b/src/main/java/com/chooz/comment/presentation/CommentController.java @@ -0,0 +1,62 @@ +package com.chooz.comment.presentation; + +import com.chooz.auth.domain.UserInfo; +import com.chooz.comment.application.CommentService; +import com.chooz.comment.presentation.dto.CommentIdResponse; +import com.chooz.comment.presentation.dto.CommentRequest; +import com.chooz.comment.presentation.dto.CommentResponse; +import com.chooz.common.dto.CursorBasePaginatedResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts/{postId}/comments") +public class CommentController { + + private final CommentService commentService; + + @GetMapping("") + public ResponseEntity findComments( + @PathVariable("postId") Long postId, + @RequestParam(value = "cursor", required = false) Long cursor, + @RequestParam(value = "size", defaultValue = "10") int size, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(commentService.findComments(postId, userInfo.userId(), cursor, size)); + } + + @PostMapping("") + public ResponseEntity createComment( + @PathVariable("postId") Long postId, + @Valid @RequestBody CommentRequest commentRequest, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(commentService.createComment(postId, commentRequest, userInfo.userId())); + } + + @PatchMapping("/{commentId}") + public ResponseEntity updateComment( + @PathVariable("postId") Long postId, + @PathVariable("commentId") Long commentId, + @Valid @RequestBody CommentRequest commentRequest, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(commentService.updateComment(postId, commentId,commentRequest, userInfo.userId())); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment( + @PathVariable("postId") Long postId, + @PathVariable("commentId") Long commentId, + @AuthenticationPrincipal UserInfo userInfo + ) { + commentService.deleteComment(postId, commentId, userInfo.userId()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/chooz/comment/presentation/dto/CommentAuthorDto.java b/src/main/java/com/chooz/comment/presentation/dto/CommentAuthorDto.java new file mode 100644 index 00000000..5cff2463 --- /dev/null +++ b/src/main/java/com/chooz/comment/presentation/dto/CommentAuthorDto.java @@ -0,0 +1,13 @@ +package com.chooz.comment.presentation.dto; + +import com.chooz.user.domain.User; + +public record CommentAuthorDto( + Long userId, + String nickname, + String profileUrl +) { + public static CommentAuthorDto of (User user) { + return new CommentAuthorDto(user.getId(), user.getNickname(), user.getProfileUrl()); + } +} diff --git a/src/main/java/com/chooz/comment/presentation/dto/CommentDto.java b/src/main/java/com/chooz/comment/presentation/dto/CommentDto.java new file mode 100644 index 00000000..7beba1fb --- /dev/null +++ b/src/main/java/com/chooz/comment/presentation/dto/CommentDto.java @@ -0,0 +1,36 @@ +package com.chooz.comment.presentation.dto; + +import com.chooz.comment.domain.Comment; +import com.chooz.common.dto.CursorDto; + +import java.time.LocalDateTime; + +public record CommentDto( + Long id, + String content, + boolean edited, + LocalDateTime createdAt, + CommentAuthorDto author, + CommentLikeDto like +) implements CursorDto { + + public static CommentDto of ( + Comment comment, + CommentAuthorDto author, + CommentLikeDto like + ){ + return new CommentDto( + comment.getId(), + comment.getContent(), + comment.getEdited(), + comment.getCreatedAt(), + author, + like + ); + } + + @Override + public long getId() { + return this.id; + } +} diff --git a/src/main/java/com/chooz/comment/presentation/dto/CommentIdResponse.java b/src/main/java/com/chooz/comment/presentation/dto/CommentIdResponse.java new file mode 100644 index 00000000..4ead595e --- /dev/null +++ b/src/main/java/com/chooz/comment/presentation/dto/CommentIdResponse.java @@ -0,0 +1,5 @@ +package com.chooz.comment.presentation.dto; + +public record CommentIdResponse( + Long commentId +){} diff --git a/src/main/java/com/chooz/comment/presentation/dto/CommentLikeDto.java b/src/main/java/com/chooz/comment/presentation/dto/CommentLikeDto.java new file mode 100644 index 00000000..cdc9e8df --- /dev/null +++ b/src/main/java/com/chooz/comment/presentation/dto/CommentLikeDto.java @@ -0,0 +1,12 @@ +package com.chooz.comment.presentation.dto; + + +public record CommentLikeDto( + Long commentLikeId, + boolean liked, + int likeCount +) { + public static CommentLikeDto of(Long id, boolean liked, int likeCount) { + return new CommentLikeDto(id, liked, likeCount); + } +} diff --git a/src/main/java/com/swyp8team2/comment/presentation/dto/CommentRequest.java b/src/main/java/com/chooz/comment/presentation/dto/CommentRequest.java similarity index 72% rename from src/main/java/com/swyp8team2/comment/presentation/dto/CommentRequest.java rename to src/main/java/com/chooz/comment/presentation/dto/CommentRequest.java index 5abf1633..3fec4a57 100644 --- a/src/main/java/com/swyp8team2/comment/presentation/dto/CommentRequest.java +++ b/src/main/java/com/chooz/comment/presentation/dto/CommentRequest.java @@ -1,4 +1,4 @@ -package com.swyp8team2.comment.presentation.dto; +package com.chooz.comment.presentation.dto; import jakarta.validation.constraints.NotEmpty; diff --git a/src/main/java/com/chooz/comment/presentation/dto/CommentResponse.java b/src/main/java/com/chooz/comment/presentation/dto/CommentResponse.java new file mode 100644 index 00000000..899b9e21 --- /dev/null +++ b/src/main/java/com/chooz/comment/presentation/dto/CommentResponse.java @@ -0,0 +1,8 @@ +package com.chooz.comment.presentation.dto; + +import com.chooz.common.dto.CursorBasePaginatedResponse; + +public record CommentResponse( + int commentCount, + CursorBasePaginatedResponse comments +){} diff --git a/src/main/java/com/chooz/comment/support/CommentValidator.java b/src/main/java/com/chooz/comment/support/CommentValidator.java new file mode 100644 index 00000000..1f05ce1f --- /dev/null +++ b/src/main/java/com/chooz/comment/support/CommentValidator.java @@ -0,0 +1,39 @@ +package com.chooz.comment.support; + +import com.chooz.comment.domain.Comment; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.CommentActive; +import org.springframework.stereotype.Component; + +@Component +public class CommentValidator { + public void validateCommentOwnership(Comment comment, Long userId) { + if (!comment.getUserId().equals(userId)) { + throw new BadRequestException(ErrorCode.NOT_COMMENT_AUTHOR); + } + } + + public void validateCommentBelongsToPost(Comment comment, Long postId) { + if (!comment.getPostId().equals(postId)) { + throw new BadRequestException(ErrorCode.COMMENT_NOT_BELONG_TO_POST); + } + } + + public void validateCommentAccess(Comment comment, Long postId, Long userId) { + validateCommentBelongsToPost(comment, postId); + validateCommentOwnership(comment, userId); + } + public void validateCommentActive(CommentActive commentActive) { + if(commentActive.equals(CommentActive.CLOSED)) { + throw new BadRequestException(ErrorCode.CLOSE_COMMENT_ACTIVE); + } + } + public void validateContentLength(String content){ + if(content.length() > 200) { + throw new BadRequestException(ErrorCode.COMMENT_LENGTH_OVER); + } + } + + +} diff --git a/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java b/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java new file mode 100644 index 00000000..b65c4295 --- /dev/null +++ b/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java @@ -0,0 +1,60 @@ +package com.chooz.commentLike.application; + +import com.chooz.commentLike.domain.CommentLike; +import com.chooz.commentLike.domain.CommentLikeRepository; +import com.chooz.commentLike.presentation.dto.CommentLikeIdResponse; +import com.chooz.common.event.EventPublisher; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.commentLike.domain.event.CommentLikedNotificationEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class CommentLikeCommandService { + + private final CommentLikeRepository commentLikeRepository; + private final EventPublisher eventPublisher; + + public CommentLikeIdResponse createCommentLike(Long commentId, Long userId) { + if(commentLikeRepository.existsByCommentIdAndUserId(commentId, userId)){ + throw new BadRequestException(ErrorCode.COMMENT_LIKE_NOT_FOUND); + } + CommentLike commentLike = commentLikeRepository.save(CommentLike.create(commentId, userId)); + + eventPublisher.publish(new CommentLikedNotificationEvent( + commentId, + commentLike.getId(), + userId, + LocalDateTime.now() + )); + return new CommentLikeIdResponse( + commentLike.getId(), + commentLikeRepository.countByCommentId(commentId) + ); + } + + public CommentLikeIdResponse deleteCommentLike(Long commentId, Long commentLikeId, Long userId) { + CommentLike commentLike = commentLikeRepository.findById(commentLikeId) + .orElseThrow(() -> new BadRequestException(ErrorCode.COMMENT_LIKE_NOT_FOUND)); + if(!commentLike.getUserId().equals(userId)){ + throw new BadRequestException(ErrorCode.NOT_COMMENT_LIKE_AUTHOR); + } + commentLikeRepository.delete(commentLike); + return new CommentLikeIdResponse( + null, + commentLikeRepository.countByCommentId(commentId) + ); + } + + public void deleteCommentLikeByCommentId(Long commentId) { + commentLikeRepository.deleteByCommentId(commentId); + } +} diff --git a/src/main/java/com/chooz/commentLike/application/CommentLikeService.java b/src/main/java/com/chooz/commentLike/application/CommentLikeService.java new file mode 100644 index 00000000..ce3e7c4e --- /dev/null +++ b/src/main/java/com/chooz/commentLike/application/CommentLikeService.java @@ -0,0 +1,26 @@ +package com.chooz.commentLike.application; + +import com.chooz.commentLike.presentation.dto.CommentLikeIdResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class CommentLikeService { + + private final CommentLikeCommandService commentLikeCommandService; + + @Transactional + public CommentLikeIdResponse createCommentLike(Long commentId, Long userId) { + return commentLikeCommandService.createCommentLike(commentId, userId); + } + + @Transactional + public CommentLikeIdResponse deleteCommentLike(Long commentId, Long commentLikeId, Long userId) { + return commentLikeCommandService.deleteCommentLike(commentId, commentLikeId, userId); + } +} diff --git a/src/main/java/com/chooz/commentLike/domain/CommentLike.java b/src/main/java/com/chooz/commentLike/domain/CommentLike.java new file mode 100644 index 00000000..0f14182e --- /dev/null +++ b/src/main/java/com/chooz/commentLike/domain/CommentLike.java @@ -0,0 +1,31 @@ +package com.chooz.commentLike.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Entity +@Table( + name = "likes", + uniqueConstraints = @UniqueConstraint(columnNames = {"comment_id", "user_id"}) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class CommentLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long commentId; + + @NotNull + private Long userId; + + public static CommentLike create(Long commentId, Long userId) { + return new CommentLike(null, commentId, userId); + } +} diff --git a/src/main/java/com/chooz/commentLike/domain/CommentLikeCountProjection.java b/src/main/java/com/chooz/commentLike/domain/CommentLikeCountProjection.java new file mode 100644 index 00000000..b1baf63a --- /dev/null +++ b/src/main/java/com/chooz/commentLike/domain/CommentLikeCountProjection.java @@ -0,0 +1,6 @@ +package com.chooz.commentLike.domain; + +public interface CommentLikeCountProjection { + Long getCommentId(); + Long getLikeCount(); +} diff --git a/src/main/java/com/chooz/commentLike/domain/CommentLikeRepository.java b/src/main/java/com/chooz/commentLike/domain/CommentLikeRepository.java new file mode 100644 index 00000000..8d3c11b9 --- /dev/null +++ b/src/main/java/com/chooz/commentLike/domain/CommentLikeRepository.java @@ -0,0 +1,38 @@ +package com.chooz.commentLike.domain; + +import jakarta.validation.constraints.NotNull; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface CommentLikeRepository extends JpaRepository { + + boolean existsByCommentIdAndUserId(Long commentId, Long userId); + + List findByCommentIdInAndUserId(List commentIds, Long userId); + + Optional findByCommentIdAndUserId(Long commentId, Long userId); + + @Query(""" + SELECT cl.commentId AS commentId, COUNT(cl) AS likeCount + FROM CommentLike cl + WHERE cl.commentId IN :commentIds + GROUP BY cl.commentId + """) + List countByCommentIds(@Param("commentIds") List commentIds); + + @Modifying + @Query(""" + DELETE FROM CommentLike cl + WHERE cl.commentId = :commentId + """) + void deleteByCommentId(@Param("commentId") Long commentId); + + Integer countByCommentId(Long commentId); +} diff --git a/src/main/java/com/chooz/commentLike/domain/event/CommentLikedNotificationEvent.java b/src/main/java/com/chooz/commentLike/domain/event/CommentLikedNotificationEvent.java new file mode 100644 index 00000000..e6a4d7e3 --- /dev/null +++ b/src/main/java/com/chooz/commentLike/domain/event/CommentLikedNotificationEvent.java @@ -0,0 +1,11 @@ +package com.chooz.commentLike.domain.event; + +import java.time.LocalDateTime; + +public record CommentLikedNotificationEvent( + Long commentId, + Long commentLikeId, + Long likerId, + LocalDateTime eventAt +) {} + diff --git a/src/main/java/com/chooz/commentLike/presentation/CommentLikeController.java b/src/main/java/com/chooz/commentLike/presentation/CommentLikeController.java new file mode 100644 index 00000000..ec99fa14 --- /dev/null +++ b/src/main/java/com/chooz/commentLike/presentation/CommentLikeController.java @@ -0,0 +1,36 @@ +package com.chooz.commentLike.presentation; + +import com.chooz.auth.domain.UserInfo; +import com.chooz.commentLike.application.CommentLikeService; +import com.chooz.commentLike.presentation.dto.CommentLikeIdResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/comment-likes") +public class CommentLikeController { + + private final CommentLikeService commentLikeService; + + @PostMapping("/{commentId}") + public ResponseEntity createCommentLike( + @PathVariable("commentId") Long commentId, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(commentLikeService.createCommentLike(commentId, userInfo.userId())); + } + + @DeleteMapping("/{commentId}/{commentLikeId}") + public ResponseEntity deleteCommentLike( + @PathVariable("commentId") Long commentId, + @PathVariable("commentLikeId") Long commentLikeId, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(commentLikeService.deleteCommentLike(commentId, commentLikeId, userInfo.userId())); + } +} diff --git a/src/main/java/com/chooz/commentLike/presentation/dto/CommentLikeIdResponse.java b/src/main/java/com/chooz/commentLike/presentation/dto/CommentLikeIdResponse.java new file mode 100644 index 00000000..306e90d0 --- /dev/null +++ b/src/main/java/com/chooz/commentLike/presentation/dto/CommentLikeIdResponse.java @@ -0,0 +1,9 @@ +package com.chooz.commentLike.presentation.dto; + +import software.amazon.awssdk.services.s3.endpoints.internal.Value; + +public record CommentLikeIdResponse ( + Long commentLikeId, + Integer likeCount +) { +} diff --git a/src/main/java/com/chooz/common/config/CommonConfig.java b/src/main/java/com/chooz/common/config/CommonConfig.java new file mode 100644 index 00000000..420f07c4 --- /dev/null +++ b/src/main/java/com/chooz/common/config/CommonConfig.java @@ -0,0 +1,29 @@ +package com.chooz.common.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.TimeZone; + +@Configuration +@EnableScheduling +@ConfigurationPropertiesScan(basePackages = "com.chooz") +public class CommonConfig { + + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + @Bean + public Clock clock() { + System.out.println("LocalDateTime.now() = " + LocalDateTime.now()); + return Clock.systemDefaultZone(); + } + +} diff --git a/src/main/java/com/swyp8team2/common/config/CorsConfig.java b/src/main/java/com/chooz/common/config/CorsConfig.java similarity index 72% rename from src/main/java/com/swyp8team2/common/config/CorsConfig.java rename to src/main/java/com/chooz/common/config/CorsConfig.java index 5ac3563e..dd7f5c47 100644 --- a/src/main/java/com/swyp8team2/common/config/CorsConfig.java +++ b/src/main/java/com/chooz/common/config/CorsConfig.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.config; +package com.chooz.common.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,17 +15,29 @@ public class CorsConfig { @Profile("prod") public UrlBasedCorsConfigurationSource corsConfigurationSourceProd() { CorsConfiguration configuration = getCorsConfiguration(); - configuration.setAllowedOrigins(List.of("https://photopic.site", "https://www.photopic.site")); + configuration.setAllowedOrigins( + List.of( + "https://chooz.site", + "https://www.chooz.site", + "https://www.photopic.site" + ) + ); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } @Bean - @Profile({"local", "dev", "default", "test"}) + @Profile("!prod") public UrlBasedCorsConfigurationSource corsConfigurationSourceLocal() { CorsConfiguration configuration = getCorsConfiguration(); - configuration.setAllowedOrigins(List.of("http://localhost:5173", "https://dev.photopic.site")); + configuration.setAllowedOrigins( + List.of( + "http://localhost:5173", + "https://dev.chooz.site", + "https://dev.photopic.site" + ) + ); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; diff --git a/src/main/java/com/swyp8team2/common/config/HttpInterfaceConfig.java b/src/main/java/com/chooz/common/config/HttpInterfaceConfig.java similarity index 83% rename from src/main/java/com/swyp8team2/common/config/HttpInterfaceConfig.java rename to src/main/java/com/chooz/common/config/HttpInterfaceConfig.java index 6f385379..737ec0a5 100644 --- a/src/main/java/com/swyp8team2/common/config/HttpInterfaceConfig.java +++ b/src/main/java/com/chooz/common/config/HttpInterfaceConfig.java @@ -1,7 +1,6 @@ -package com.swyp8team2.common.config; +package com.chooz.common.config; -import com.swyp8team2.auth.application.oauth.KakaoOAuthClient; -import com.swyp8team2.common.exception.DiscordClient; +import com.chooz.auth.application.oauth.KakaoOAuthClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestClient; diff --git a/src/main/java/com/swyp8team2/common/config/JpaConfig.java b/src/main/java/com/chooz/common/config/JpaConfig.java similarity index 83% rename from src/main/java/com/swyp8team2/common/config/JpaConfig.java rename to src/main/java/com/chooz/common/config/JpaConfig.java index 9109b4a3..e220107e 100644 --- a/src/main/java/com/swyp8team2/common/config/JpaConfig.java +++ b/src/main/java/com/chooz/common/config/JpaConfig.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.config; +package com.chooz.common.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; diff --git a/src/main/java/com/swyp8team2/common/config/KakaoOAuthConfig.java b/src/main/java/com/chooz/common/config/KakaoOAuthConfig.java similarity index 88% rename from src/main/java/com/swyp8team2/common/config/KakaoOAuthConfig.java rename to src/main/java/com/chooz/common/config/KakaoOAuthConfig.java index 7fcf0fe9..3b213aca 100644 --- a/src/main/java/com/swyp8team2/common/config/KakaoOAuthConfig.java +++ b/src/main/java/com/chooz/common/config/KakaoOAuthConfig.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.config; +package com.chooz.common.config; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/chooz/common/config/QueryDslConfig.java b/src/main/java/com/chooz/common/config/QueryDslConfig.java new file mode 100644 index 00000000..555f9cab --- /dev/null +++ b/src/main/java/com/chooz/common/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package com.chooz.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/swyp8team2/common/config/SecurityConfig.java b/src/main/java/com/chooz/common/config/SecurityConfig.java similarity index 91% rename from src/main/java/com/swyp8team2/common/config/SecurityConfig.java rename to src/main/java/com/chooz/common/config/SecurityConfig.java index 9bfdfd1f..39ec3158 100644 --- a/src/main/java/com/swyp8team2/common/config/SecurityConfig.java +++ b/src/main/java/com/chooz/common/config/SecurityConfig.java @@ -1,11 +1,11 @@ -package com.swyp8team2.common.config; +package com.chooz.common.config; -import com.swyp8team2.auth.application.jwt.JwtProvider; -import com.swyp8team2.auth.presentation.filter.CustomAccessDenialHandler; -import com.swyp8team2.auth.presentation.filter.HeaderTokenExtractor; -import com.swyp8team2.auth.presentation.filter.JwtAuthFilter; -import com.swyp8team2.auth.presentation.filter.JwtAuthenticationEntryPoint; -import com.swyp8team2.user.domain.Role; +import com.chooz.auth.application.jwt.JwtProvider; +import com.chooz.auth.presentation.filter.CustomAccessDenialHandler; +import com.chooz.auth.presentation.filter.HeaderTokenExtractor; +import com.chooz.auth.presentation.filter.JwtAuthFilter; +import com.chooz.auth.presentation.filter.JwtAuthenticationEntryPoint; +import com.chooz.user.domain.Role; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; @@ -84,7 +84,7 @@ public SecurityFilterChain securityFilterChain( authorize .requestMatchers(getWhiteList(introspect)).permitAll() .requestMatchers(getGuestAllowedList(introspect)) - .hasAnyRole(Role.USER.name(), Role.GUEST.name()) + .hasAnyRole(Role.USER.name()) .anyRequest().hasRole(Role.USER.name())) .addFilterBefore( diff --git a/src/main/java/com/swyp8team2/common/dev/DataInitConfig.java b/src/main/java/com/chooz/common/dev/DataInitConfig.java similarity index 92% rename from src/main/java/com/swyp8team2/common/dev/DataInitConfig.java rename to src/main/java/com/chooz/common/dev/DataInitConfig.java index 561a4c99..f6418ca2 100644 --- a/src/main/java/com/swyp8team2/common/dev/DataInitConfig.java +++ b/src/main/java/com/chooz/common/dev/DataInitConfig.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.dev; +package com.chooz.common.dev; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/chooz/common/dev/DataInitializer.java b/src/main/java/com/chooz/common/dev/DataInitializer.java new file mode 100644 index 00000000..c7a72dd9 --- /dev/null +++ b/src/main/java/com/chooz/common/dev/DataInitializer.java @@ -0,0 +1,109 @@ +package com.chooz.common.dev; + +import com.chooz.auth.application.jwt.JwtService; +import com.chooz.comment.domain.CommentRepository; +import com.chooz.post.domain.CloseOption; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.CommentActive; +import com.chooz.post.domain.PollChoice; +import com.chooz.post.domain.PollOption; +import com.chooz.post.domain.PollType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.post.domain.Scope; +import com.chooz.user.domain.NicknameAdjectiveRepository; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.application.VoteService; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Profile({"!prod", "!test"}) +@Component +public class DataInitializer { + + private final NicknameAdjectiveRepository nicknameAdjectiveRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + private final JwtService jwtService; + private final VoteService voteService; + private final CommentRepository commentRepository; + + public DataInitializer( + NicknameAdjectiveRepository nicknameAdjectiveRepository, + UserRepository userRepository, + PostRepository postRepository, + JwtService jwtService, + VoteService voteService, + CommentRepository commentRepository + ) { + this.nicknameAdjectiveRepository = nicknameAdjectiveRepository; + this.userRepository = userRepository; + this.postRepository = postRepository; + this.jwtService = jwtService; + this.voteService = voteService; + this.commentRepository = commentRepository; + } + + + @Transactional + public void init() { + User user = userRepository.save(User.create("chooz1", "https://t1.kakaocdn.net/account_images/default_profile.jpeg")); + User user2 = userRepository.save(User.create("chooz2", "https://t1.kakaocdn.net/account_images/default_profile.jpeg")); + postRepository.save(Post.create( + user.getId(), + "title", + "description", + "imageUrl", + List.of(PollChoice.create("title1", "imageUrl1"), PollChoice.create("title1", "imageUrl1")), + "shareUrl", + PollOption.create(PollType.SINGLE, Scope.PUBLIC, CommentActive.OPEN), + CloseOption.create(CloseType.VOTER, null, 2))); + postRepository.save(Post.create( + user.getId(), + "title", + "description", + "imageUrl", + List.of(PollChoice.create("title1", "imageUrl1"), PollChoice.create("title1", "imageUrl1")), + "shareUrl", + PollOption.create(PollType.SINGLE, Scope.PUBLIC, CommentActive.OPEN), + new CloseOption(CloseType.DATE, LocalDateTime.now().plusMinutes(5), null))); +// TokenResponse tokenResponse = jwtService.createToken(new JwtClaim(testUser.getId(), testUser.getRole())); +// TokenPair tokenPair = tokenResponse.tokenPair(); +// System.out.println("accessToken = " + tokenPair.accessToken()); +// System.out.println("refreshToken = " + tokenPair.refreshToken()); +// List users = new ArrayList<>(); +// List posts = new ArrayList<>(); +// for (int i = 0; i < 10; i++) { +// String userName = adjectives.size() < 10 ? "user" + i : adjectives.get(i).getAdjective(); +// User user = userRepository.save(User.create(userName, "https://t1.kakaocdn.net/account_images/default_profile.jpeg")); +// users.add(user); +// for (int j = 0; j < 30; j += 2) { +// ImageFile imageFile1 = imageFileRepository.save(ImageFile.create(new ImageFileDto("202502240006030.png", "https://image.chooz.site/images-dev/202502240006030.png", "https://image.chooz.site/images-dev/resized_202502240006030.png"))); +// ImageFile imageFile2 = imageFileRepository.save(ImageFile.create(new ImageFileDto("202502240006030.png", "https://image.chooz.site/images-dev/202502240006030.png", "https://image.chooz.site/images-dev/resized_202502240006030.png"))); +// Post post = postRepository.save(Post.create( +// user.getId(), +// "title" + j, +// "description" + j, +// List.of(PollChoice.create("λ½€λ˜A", imageFile1.getId()), PollChoice.create("λ½€λ˜B", imageFile2.getId())), +// PollOption.create(PollType.SINGLE, Scope.PUBLIC, CommentActive.OPEN), +// CloseOption.create(CloseType.SELF, null, null) +// )); +// posts.add(post); +// } +// +// } +// for (User user : users) { +// for (Post post : posts) { +// Random random = new Random(); +// int num = random.nextInt(2); +// voteService.vote(user.getId(), post.getId(), post.getPollChoices().get(num).getId()); +// commentRepository.save(new Comment(post.getId(), user.getId(), "λŒ“κΈ€ λ‚΄μš©" + random.nextInt(100))); +// } +// } + } +} diff --git a/src/main/java/com/chooz/common/domain/AuditorAwareImpl.java b/src/main/java/com/chooz/common/domain/AuditorAwareImpl.java new file mode 100644 index 00000000..f77fa637 --- /dev/null +++ b/src/main/java/com/chooz/common/domain/AuditorAwareImpl.java @@ -0,0 +1,2 @@ +package com.chooz.common.domain;public class AuditorAwareImpl { +} diff --git a/src/main/java/com/swyp8team2/common/domain/BaseEntity.java b/src/main/java/com/chooz/common/domain/BaseEntity.java similarity index 96% rename from src/main/java/com/swyp8team2/common/domain/BaseEntity.java rename to src/main/java/com/chooz/common/domain/BaseEntity.java index fb55f796..1737ac4d 100644 --- a/src/main/java/com/swyp8team2/common/domain/BaseEntity.java +++ b/src/main/java/com/chooz/common/domain/BaseEntity.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.domain; +package com.chooz.common.domain; import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; diff --git a/src/main/java/com/swyp8team2/common/dto/CursorBasePaginatedResponse.java b/src/main/java/com/chooz/common/dto/CursorBasePaginatedResponse.java similarity index 93% rename from src/main/java/com/swyp8team2/common/dto/CursorBasePaginatedResponse.java rename to src/main/java/com/chooz/common/dto/CursorBasePaginatedResponse.java index 379f2f23..91a03616 100644 --- a/src/main/java/com/swyp8team2/common/dto/CursorBasePaginatedResponse.java +++ b/src/main/java/com/chooz/common/dto/CursorBasePaginatedResponse.java @@ -1,7 +1,6 @@ -package com.swyp8team2.common.dto; +package com.chooz.common.dto; import org.springframework.data.domain.Slice; - import java.util.List; public record CursorBasePaginatedResponse( diff --git a/src/main/java/com/swyp8team2/common/dto/CursorDto.java b/src/main/java/com/chooz/common/dto/CursorDto.java similarity index 59% rename from src/main/java/com/swyp8team2/common/dto/CursorDto.java rename to src/main/java/com/chooz/common/dto/CursorDto.java index bb4ee23f..8437f73d 100644 --- a/src/main/java/com/swyp8team2/common/dto/CursorDto.java +++ b/src/main/java/com/chooz/common/dto/CursorDto.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.dto; +package com.chooz.common.dto; public interface CursorDto { diff --git a/src/main/java/com/chooz/common/event/DeleteEvent.java b/src/main/java/com/chooz/common/event/DeleteEvent.java new file mode 100644 index 00000000..aef62bce --- /dev/null +++ b/src/main/java/com/chooz/common/event/DeleteEvent.java @@ -0,0 +1,7 @@ +package com.chooz.common.event; + +public record DeleteEvent(Long id, String domain) { + public static DeleteEvent of(Long id, String domain) { + return new DeleteEvent(id, domain); + } +} diff --git a/src/main/java/com/chooz/common/event/EventPublisher.java b/src/main/java/com/chooz/common/event/EventPublisher.java new file mode 100644 index 00000000..c26f8d4a --- /dev/null +++ b/src/main/java/com/chooz/common/event/EventPublisher.java @@ -0,0 +1,5 @@ +package com.chooz.common.event; + +public interface EventPublisher { + void publish(T event); +} diff --git a/src/main/java/com/chooz/common/event/SpringEventPublisher.java b/src/main/java/com/chooz/common/event/SpringEventPublisher.java new file mode 100644 index 00000000..e5f6297c --- /dev/null +++ b/src/main/java/com/chooz/common/event/SpringEventPublisher.java @@ -0,0 +1,17 @@ +package com.chooz.common.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpringEventPublisher implements EventPublisher { + + private final ApplicationEventPublisher eventPublisher; + + @Override + public void publish(T event) { + eventPublisher.publishEvent(event); + } +} diff --git a/src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java b/src/main/java/com/chooz/common/exception/ApplicationControllerAdvice.java similarity index 86% rename from src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java rename to src/main/java/com/chooz/common/exception/ApplicationControllerAdvice.java index 1de6b396..9f4cce4c 100644 --- a/src/main/java/com/swyp8team2/common/exception/ApplicationControllerAdvice.java +++ b/src/main/java/com/chooz/common/exception/ApplicationControllerAdvice.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,7 +17,6 @@ import org.springframework.web.servlet.resource.NoResourceFoundException; import javax.naming.AuthenticationException; -import java.nio.file.AccessDeniedException; import java.util.Arrays; @Slf4j @@ -30,14 +29,14 @@ public class ApplicationControllerAdvice { @ExceptionHandler(BadRequestException.class) public ResponseEntity handle(BadRequestException e) { - ErrorResponse response = new ErrorResponse(e.getErrorCode()); + ErrorResponse response = ErrorResponse.of(e.getErrorCode()); return ResponseEntity.badRequest() .body(response); } @ExceptionHandler(UnauthorizedException.class) public ResponseEntity handle(UnauthorizedException e) { - ErrorResponse response = new ErrorResponse(e.getErrorCode()); + ErrorResponse response = ErrorResponse.of(e.getErrorCode()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(response); } @@ -51,7 +50,7 @@ public ResponseEntity handle(UnauthorizedException e) { public ResponseEntity invalidArgument(Exception e) { log.debug("invalidArgument: {}", e.getMessage()); return ResponseEntity.badRequest() - .body(new ErrorResponse(ErrorCode.INVALID_ARGUMENT)); + .body(ErrorResponse.of(ErrorCode.INVALID_ARGUMENT)); } @ExceptionHandler({ @@ -62,19 +61,19 @@ public ResponseEntity invalidArgument(Exception e) { public ResponseEntity notFound(Exception e) { log.debug("notFound: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ErrorResponse(ErrorCode.NOT_FOUND)); + .body(ErrorResponse.of(ErrorCode.NOT_FOUND)); } @ExceptionHandler(AuthenticationException.class) public ResponseEntity handle(AuthenticationException e) { log.debug(e.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse(ErrorCode.INVALID_TOKEN)); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ErrorResponse.of(ErrorCode.INVALID_TOKEN)); } @ExceptionHandler(ForbiddenException.class) public ResponseEntity handle(ForbiddenException e) { log.debug(e.getMessage()); - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new ErrorResponse(ErrorCode.FORBIDDEN)); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ErrorResponse.of(ErrorCode.FORBIDDEN)); } @ExceptionHandler(Exception.class) @@ -84,6 +83,6 @@ public ResponseEntity handle(Exception e, WebRequest webRequest) discordMessageSender.sendDiscordAlarm(e, webRequest); } return ResponseEntity.internalServerError() - .body(new ErrorResponse(ErrorCode.INTERNAL_SERVER_ERROR)); + .body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR)); } } diff --git a/src/main/java/com/swyp8team2/common/exception/ApplicationException.java b/src/main/java/com/chooz/common/exception/ApplicationException.java similarity index 90% rename from src/main/java/com/swyp8team2/common/exception/ApplicationException.java rename to src/main/java/com/chooz/common/exception/ApplicationException.java index bad6539c..d7d61394 100644 --- a/src/main/java/com/swyp8team2/common/exception/ApplicationException.java +++ b/src/main/java/com/chooz/common/exception/ApplicationException.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; import lombok.Getter; diff --git a/src/main/java/com/swyp8team2/common/exception/BadRequestException.java b/src/main/java/com/chooz/common/exception/BadRequestException.java similarity index 78% rename from src/main/java/com/swyp8team2/common/exception/BadRequestException.java rename to src/main/java/com/chooz/common/exception/BadRequestException.java index c428ca56..0f358228 100644 --- a/src/main/java/com/swyp8team2/common/exception/BadRequestException.java +++ b/src/main/java/com/chooz/common/exception/BadRequestException.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; public class BadRequestException extends ApplicationException { diff --git a/src/main/java/com/swyp8team2/common/exception/DiscordClient.java b/src/main/java/com/chooz/common/exception/DiscordClient.java similarity index 94% rename from src/main/java/com/swyp8team2/common/exception/DiscordClient.java rename to src/main/java/com/chooz/common/exception/DiscordClient.java index 53de6ae4..fd877f14 100644 --- a/src/main/java/com/swyp8team2/common/exception/DiscordClient.java +++ b/src/main/java/com/chooz/common/exception/DiscordClient.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/swyp8team2/common/exception/DiscordMessage.java b/src/main/java/com/chooz/common/exception/DiscordMessage.java similarity index 85% rename from src/main/java/com/swyp8team2/common/exception/DiscordMessage.java rename to src/main/java/com/chooz/common/exception/DiscordMessage.java index 9f966081..1cbddee3 100644 --- a/src/main/java/com/swyp8team2/common/exception/DiscordMessage.java +++ b/src/main/java/com/chooz/common/exception/DiscordMessage.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; import lombok.Builder; diff --git a/src/main/java/com/swyp8team2/common/exception/DiscordMessageSender.java b/src/main/java/com/chooz/common/exception/DiscordMessageSender.java similarity index 98% rename from src/main/java/com/swyp8team2/common/exception/DiscordMessageSender.java rename to src/main/java/com/chooz/common/exception/DiscordMessageSender.java index 122e0d53..07aed167 100644 --- a/src/main/java/com/swyp8team2/common/exception/DiscordMessageSender.java +++ b/src/main/java/com/chooz/common/exception/DiscordMessageSender.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/chooz/common/exception/ErrorCode.java b/src/main/java/com/chooz/common/exception/ErrorCode.java new file mode 100644 index 00000000..37ec126b --- /dev/null +++ b/src/main/java/com/chooz/common/exception/ErrorCode.java @@ -0,0 +1,80 @@ +package com.chooz.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + //400 + USER_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μœ μ €μž…λ‹ˆλ‹€."), + INVALID_ARGUMENT("잘λͺ»λœ νŒŒλΌλ―Έν„° μš”μ²­μž…λ‹ˆλ‹€."), + REFRESH_TOKEN_MISMATCHED("λ¦¬ν”„λ ˆμ‹œ 토큰이 λΆˆμΌμΉ˜ν•©λ‹ˆλ‹€."), + REFRESH_TOKEN_NOT_FOUND("λ¦¬ν”„λ ˆμ‹œ 토큰을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + INVALID_REFRESH_TOKEN_HEADER("잘λͺ»λœ λ¦¬ν”„λ ˆμ‹œ 토큰 ν—€λ”μž…λ‹ˆλ‹€."), + MISSING_FILE_EXTENSION("ν™•μž₯μžκ°€ λˆ„λ½λμŠ΅λ‹ˆλ‹€."), + UNSUPPORTED_IMAGE_EXTENSION("μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν™•μž₯μžμž…λ‹ˆλ‹€."), + EXCEED_MAX_FILE_SIZE("파일 크기가 μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€."), + POST_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” κ²Œμ‹œκΈ€μž…λ‹ˆλ‹€."), + DESCRIPTION_LENGTH_EXCEEDED("κ²Œμ‹œκΈ€ μ„€λͺ… 길이가 μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€."), + TITLE_IS_REQUIRED("κ²Œμ‹œκΈ€ 제λͺ©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€."), + TITLE_LENGTH_EXCEEDED("κ²Œμ‹œκΈ€ 제λͺ© 길이가 μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€."), + INVALID_POLL_CHOICE_COUNT("νˆ¬ν‘œ 선택지 κ°œμˆ˜κ°€ λ²”μœ„λ₯Ό λ²—μ–΄λ‚¬μŠ΅λ‹ˆλ‹€."), + POLL_CHOICE_TITLE_LENGTH_EXCEEDED("νˆ¬ν‘œ 선택지 제λͺ© 길이가 μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€."), + NOT_POST_AUTHOR("κ²Œμ‹œκΈ€ μž‘μ„±μžκ°€ μ•„λ‹™λ‹ˆλ‹€."), + POST_ALREADY_CLOSED("이미 마감된 κ²Œμ‹œκΈ€μž…λ‹ˆλ‹€."), + FILE_NAME_TOO_LONG("파일 이름이 λ„ˆλ¬΄ κΉλ‹ˆλ‹€."), + ACCESS_DENIED_VOTE_STATUS("νˆ¬ν‘œ ν˜„ν™© 쑰회 κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."), + COMMENT_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λŒ“κΈ€μž…λ‹ˆλ‹€."), + VOTE_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” νˆ¬ν‘œμž…λ‹ˆλ‹€."), + NOT_VOTER("νˆ¬ν‘œμžκ°€ μ•„λ‹™λ‹ˆλ‹€."), + CLOSED_AT_REQUIRED("마감 μ‹œκ°„ 섀정이 ν•„μš”ν•©λ‹ˆλ‹€."), + MAX_VOTER_COUNT_REQUIRED("μ΅œλŒ€ νˆ¬ν‘œμž 수 섀정이 ν•„μš”ν•©λ‹ˆλ‹€."), + INVALID_VOTER_CLOSE_OPTION("잘λͺ»λœ μ΅œλŒ€ νˆ¬ν‘œμž 마감 μ„€μ •μž…λ‹ˆλ‹€."), + INVALID_DATE_CLOSE_OPTION("잘λͺ»λœ 마감 μ‹œκ°„ μ„€μ •μž…λ‹ˆλ‹€"), + INVALID_SELF_CLOSE_OPTION("잘λͺ»λœ 자체 마감 μ˜΅μ…˜μž…λ‹ˆλ‹€."), + INVALID_CLOSE_OPTION("잘λͺ»λœ 마감 μ˜΅μ…˜μž…λ‹ˆλ‹€."), + THUMBNAIL_NOT_FOUND("썸넀일을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + CLOSE_DATE_OVER("마감 μ‹œκ°„μ΄ μ§€λ‚¬μŠ΅λ‹ˆλ‹€."), + EXCEED_MAX_VOTER_COUNT("νˆ¬ν‘œ μ°Έμ—¬μž μˆ˜κ°€ μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€."), + CLOSE_COMMENT_ACTIVE("λŒ“κΈ€ κΈ°λŠ₯이 λΉ„ν™œμ„±ν™” λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€."), + COMMENT_NOT_BELONG_TO_POST("κ²Œμ‹œκΈ€μ— μ†ν•œ λŒ“κΈ€μ΄ μ•„λ‹™λ‹ˆλ‹€."), + NOT_COMMENT_AUTHOR("λŒ“κΈ€μ˜ μž‘μ„±μžκ°€ μ•„λ‹™λ‹ˆλ‹€."), + COMMENT_LENGTH_OVER("λŒ“κΈ€ 길이가 200κΈ€μžλ₯Ό μ΄ˆκ³Όν•˜μ˜€μŠ΅λ‹ˆλ‹€."), + COMMENT_LIKE_NOT_FOUND("λŒ“κΈ€μ’‹μ•„μš”λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + NOT_COMMENT_LIKE_AUTHOR("λŒ“κΈ€ μ’‹μ•„μš”λ₯Ό λˆ„λ₯Έ μœ μ €κ°€ μ•„λ‹™λ‹ˆλ‹€."), + SINGLE_POLL_ALLOWS_MAXIMUM_ONE_CHOICE("단일 νˆ¬ν‘œμΈ 경우 μ΅œλŒ€ ν•˜λ‚˜μ˜ μ„ νƒμ§€λ§Œ νˆ¬ν‘œ κ°€λŠ₯"), + DUPLICATE_POLL_CHOICE("볡수 νˆ¬ν‘œμ˜ 경우 μ€‘λ³΅λœ 선택지가 있으면 μ•ˆ 됨"), + NOT_POST_POLL_CHOICE_ID("κ²Œμ‹œκΈ€μ˜ νˆ¬ν‘œ 선택지가 μ•„λ‹˜"), + ONLY_SELF_CAN_CLOSE("μž‘μ„±μž 마감의 경우, SELF 마감 λ°©μ‹λ§Œμ΄ 마감 κ°€λŠ₯ν•©λ‹ˆλ‹€."), + INVALID_ONBOARDING_STEP("μœ νš¨ν•˜μ§€ μ•Šμ€ μ˜¨λ³΄λ”© 단계."), + NICKNAME_LENGTH_EXCEEDED("λ‹‰λ„€μž„ 길이 초과"), + NOTIFICATION_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ•Œλ¦Ό μž…λ‹ˆλ‹€."), + POST_NOT_REVEALABLE("곡개 λΆˆκ°€λŠ₯ν•œ κ²Œμ‹œκΈ€μž…λ‹ˆλ‹€."), + + //401 + EXPIRED_TOKEN("토큰이 λ§Œλ£ŒλμŠ΅λ‹ˆλ‹€."), + INVALID_TOKEN("μœ νš¨ν•˜μ§€ μ•Šμ€ ν† ν°μž…λ‹ˆλ‹€."), + INVALID_AUTH_HEADER("잘λͺ»λœ 인증 ν—€λ”μž…λ‹ˆλ‹€."), + + //403 + FORBIDDEN("κΆŒν•œ μ—†μŒ"), + + //404 + NOT_FOUND("λ¦¬μ†ŒμŠ€λ₯Ό 찾을 수 μ—†μŒ"), + + //500 + INTERNAL_SERVER_ERROR("μ„œλ²„ λ‚΄λΆ€ 였λ₯˜ λ°œμƒ"), + INVALID_INPUT_VALUE("잘λͺ»λœ μž…λ ₯ κ°’μž…λ‹ˆλ‹€."), + SOCIAL_AUTHENTICATION_FAILED("μ†Œμ…œ 둜그인이 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + POLL_CHOICE_NAME_GENERATOR_INDEX_OUT_OF_BOUND("이미지 이름 생성기 인덱슀 초과"), + IMAGE_FILE_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ΄λ―Έμ§€μž…λ‹ˆλ‹€."), + POLL_CHOICE_NOT_FOUND("νˆ¬ν‘œ 선택지가 μ—†μŠ΅λ‹ˆλ‹€."), + SHARE_URL_ALREADY_EXISTS("곡유 URL이 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€."), + + //503 + SERVICE_UNAVAILABLE("μ„œλΉ„μŠ€ 이용 λΆˆκ°€"), + ; + + private final String message; +} diff --git a/src/main/java/com/chooz/common/exception/ErrorResponse.java b/src/main/java/com/chooz/common/exception/ErrorResponse.java new file mode 100644 index 00000000..5b527892 --- /dev/null +++ b/src/main/java/com/chooz/common/exception/ErrorResponse.java @@ -0,0 +1,8 @@ +package com.chooz.common.exception; + +public record ErrorResponse(ErrorCode errorCode, String message) { + + public static ErrorResponse of(ErrorCode errorCode) { + return new ErrorResponse(errorCode, errorCode.getMessage()); + } +} diff --git a/src/main/java/com/swyp8team2/common/exception/ForbiddenException.java b/src/main/java/com/chooz/common/exception/ForbiddenException.java similarity index 77% rename from src/main/java/com/swyp8team2/common/exception/ForbiddenException.java rename to src/main/java/com/chooz/common/exception/ForbiddenException.java index 0e39910f..159083b5 100644 --- a/src/main/java/com/swyp8team2/common/exception/ForbiddenException.java +++ b/src/main/java/com/chooz/common/exception/ForbiddenException.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; public class ForbiddenException extends ApplicationException { diff --git a/src/main/java/com/swyp8team2/common/exception/InternalServerException.java b/src/main/java/com/chooz/common/exception/InternalServerException.java similarity index 89% rename from src/main/java/com/swyp8team2/common/exception/InternalServerException.java rename to src/main/java/com/chooz/common/exception/InternalServerException.java index b29c8626..49519bc8 100644 --- a/src/main/java/com/swyp8team2/common/exception/InternalServerException.java +++ b/src/main/java/com/chooz/common/exception/InternalServerException.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; public class InternalServerException extends ApplicationException { diff --git a/src/main/java/com/swyp8team2/common/exception/ServiceUnavailableException.java b/src/main/java/com/chooz/common/exception/ServiceUnavailableException.java similarity index 80% rename from src/main/java/com/swyp8team2/common/exception/ServiceUnavailableException.java rename to src/main/java/com/chooz/common/exception/ServiceUnavailableException.java index 3b15df8c..2a746ece 100644 --- a/src/main/java/com/swyp8team2/common/exception/ServiceUnavailableException.java +++ b/src/main/java/com/chooz/common/exception/ServiceUnavailableException.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; public class ServiceUnavailableException extends ApplicationException { diff --git a/src/main/java/com/swyp8team2/common/exception/UnauthorizedException.java b/src/main/java/com/chooz/common/exception/UnauthorizedException.java similarity index 79% rename from src/main/java/com/swyp8team2/common/exception/UnauthorizedException.java rename to src/main/java/com/chooz/common/exception/UnauthorizedException.java index 6c12129d..dc8d30eb 100644 --- a/src/main/java/com/swyp8team2/common/exception/UnauthorizedException.java +++ b/src/main/java/com/chooz/common/exception/UnauthorizedException.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.exception; +package com.chooz.common.exception; public class UnauthorizedException extends ApplicationException { diff --git a/src/main/java/com/swyp8team2/common/presentation/CustomHeader.java b/src/main/java/com/chooz/common/presentation/CustomHeader.java similarity index 86% rename from src/main/java/com/swyp8team2/common/presentation/CustomHeader.java rename to src/main/java/com/chooz/common/presentation/CustomHeader.java index 147385f9..f9b4512d 100644 --- a/src/main/java/com/swyp8team2/common/presentation/CustomHeader.java +++ b/src/main/java/com/chooz/common/presentation/CustomHeader.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.presentation; +package com.chooz.common.presentation; public abstract class CustomHeader { diff --git a/src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java b/src/main/java/com/chooz/common/presentation/HttpLoggingFilter.java similarity index 99% rename from src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java rename to src/main/java/com/chooz/common/presentation/HttpLoggingFilter.java index c5ee8834..ac360cbe 100644 --- a/src/main/java/com/swyp8team2/common/presentation/HttpLoggingFilter.java +++ b/src/main/java/com/chooz/common/presentation/HttpLoggingFilter.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.presentation; +package com.chooz.common.presentation; import com.nimbusds.jose.shaded.gson.Gson; import com.nimbusds.jose.shaded.gson.GsonBuilder; diff --git a/src/main/java/com/swyp8team2/common/util/DateTime.java b/src/main/java/com/chooz/common/util/DateTime.java similarity index 90% rename from src/main/java/com/swyp8team2/common/util/DateTime.java rename to src/main/java/com/chooz/common/util/DateTime.java index bc6b1f36..06aa89d8 100644 --- a/src/main/java/com/swyp8team2/common/util/DateTime.java +++ b/src/main/java/com/chooz/common/util/DateTime.java @@ -1,4 +1,4 @@ -package com.swyp8team2.common.util; +package com.chooz.common.util; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; diff --git a/src/main/java/com/swyp8team2/common/util/Validator.java b/src/main/java/com/chooz/common/util/Validator.java similarity index 81% rename from src/main/java/com/swyp8team2/common/util/Validator.java rename to src/main/java/com/chooz/common/util/Validator.java index 5e736223..5870e0b3 100644 --- a/src/main/java/com/swyp8team2/common/util/Validator.java +++ b/src/main/java/com/chooz/common/util/Validator.java @@ -1,7 +1,7 @@ -package com.swyp8team2.common.util; +package com.chooz.common.util; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.InternalServerException; import java.util.Arrays; import java.util.Objects; diff --git a/src/main/java/com/chooz/image/application/ImageNameGenerator.java b/src/main/java/com/chooz/image/application/ImageNameGenerator.java new file mode 100644 index 00000000..5a39fcbc --- /dev/null +++ b/src/main/java/com/chooz/image/application/ImageNameGenerator.java @@ -0,0 +1,13 @@ +package com.chooz.image.application; + +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class ImageNameGenerator { + + public String generate() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/main/java/com/chooz/image/application/ImageProperties.java b/src/main/java/com/chooz/image/application/ImageProperties.java new file mode 100644 index 00000000..41069c57 --- /dev/null +++ b/src/main/java/com/chooz/image/application/ImageProperties.java @@ -0,0 +1,11 @@ +package com.chooz.image.application; + +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "image") +public record ImageProperties( + @NotBlank String path, + @NotBlank String endpoint +) { +} \ No newline at end of file diff --git a/src/main/java/com/chooz/image/application/ImageService.java b/src/main/java/com/chooz/image/application/ImageService.java new file mode 100644 index 00000000..a0e156b5 --- /dev/null +++ b/src/main/java/com/chooz/image/application/ImageService.java @@ -0,0 +1,39 @@ +package com.chooz.image.application; + +import com.chooz.image.application.dto.PresignedUrlRequestDto; +import com.chooz.image.presentation.dto.PresignedUrlRequest; +import com.chooz.image.presentation.dto.PresignedUrlResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.net.URI; + +@Service +@RequiredArgsConstructor +public class ImageService { + + private final S3Client s3Client; + private final ImageNameGenerator imageNameGenerator; + private final ImageProperties imageProperties; + private final ImageValidator imageValidator; + + public PresignedUrlResponse getPresignedUrl(PresignedUrlRequest request) { + imageValidator.validate(request.contentType()); + String assetUrl = getAssetUrl(); + String signedGetUrl = getSignedGetUrl(assetUrl); + String presignedUrl = s3Client.getPresignedPutUrl( + new PresignedUrlRequestDto(request.contentType(), request.contentLength(), assetUrl) + ); + return new PresignedUrlResponse(presignedUrl, signedGetUrl, assetUrl); + } + + private String getAssetUrl() { + String imageName = imageNameGenerator.generate(); + return imageProperties.path() + imageName; + } + + private String getSignedGetUrl(String filePath) { + URI domain = URI.create(imageProperties.endpoint()); + return domain.resolve(filePath).toString(); + } +} diff --git a/src/main/java/com/chooz/image/application/ImageValidator.java b/src/main/java/com/chooz/image/application/ImageValidator.java new file mode 100644 index 00000000..e3d0279a --- /dev/null +++ b/src/main/java/com/chooz/image/application/ImageValidator.java @@ -0,0 +1,26 @@ +package com.chooz.image.application; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class ImageValidator { + + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + + private final Set allowedExtensions; + + public ImageValidator(@Value("${image.allowed-extensions}") Set allowedExtensionsConfig) { + this.allowedExtensions = allowedExtensionsConfig; + } + + public void validate(String contentType) { + if (!allowedExtensions.contains(contentType)) { + throw new BadRequestException(ErrorCode.UNSUPPORTED_IMAGE_EXTENSION); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/chooz/image/application/S3Client.java b/src/main/java/com/chooz/image/application/S3Client.java new file mode 100644 index 00000000..dcfd33be --- /dev/null +++ b/src/main/java/com/chooz/image/application/S3Client.java @@ -0,0 +1,8 @@ +package com.chooz.image.application; + +import com.chooz.image.application.dto.PresignedUrlRequestDto; +import com.chooz.image.presentation.dto.PresignedUrlRequest; + +public interface S3Client { + String getPresignedPutUrl(PresignedUrlRequestDto presignedUrlRequestDto); +} diff --git a/src/main/java/com/chooz/image/application/dto/PresignedUrlRequestDto.java b/src/main/java/com/chooz/image/application/dto/PresignedUrlRequestDto.java new file mode 100644 index 00000000..1be979af --- /dev/null +++ b/src/main/java/com/chooz/image/application/dto/PresignedUrlRequestDto.java @@ -0,0 +1,8 @@ +package com.chooz.image.application.dto; + +public record PresignedUrlRequestDto( + String contentType, + Long contentLength, + String assetUrl +) { +} diff --git a/src/main/java/com/chooz/image/infrastructure/AwsS3Client.java b/src/main/java/com/chooz/image/infrastructure/AwsS3Client.java new file mode 100644 index 00000000..fa252094 --- /dev/null +++ b/src/main/java/com/chooz/image/infrastructure/AwsS3Client.java @@ -0,0 +1,48 @@ +package com.chooz.image.infrastructure; + +import com.chooz.image.application.S3Client; +import com.chooz.image.application.dto.PresignedUrlRequestDto; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.Duration; + +@Component +public class AwsS3Client implements S3Client { + + private static final Duration PRESIGNED_URL_EXPIRATION = Duration.ofMinutes(5); + + private final String bucket; + private final S3Presigner s3Presigner; + + public AwsS3Client( + @Value("${spring.cloud.aws.s3.bucket}") String bucket, + S3Presigner s3Presigner + ) { + this.bucket = bucket; + this.s3Presigner = s3Presigner; + } + + @Override + public String getPresignedPutUrl(PresignedUrlRequestDto presignedUrlRequestDto) { + PutObjectPresignRequest presignRequest = buildPresignedRequest(presignedUrlRequestDto); + return s3Presigner.presignPutObject(presignRequest) + .url() + .toString(); + } + + private PutObjectPresignRequest buildPresignedRequest(PresignedUrlRequestDto dto) { + PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder() + .bucket(bucket) + .contentType(dto.contentType()) + .contentLength(dto.contentLength()) + .key(dto.assetUrl()); + return PutObjectPresignRequest.builder() + .signatureDuration(PRESIGNED_URL_EXPIRATION) + .putObjectRequest(requestBuilder.build()) + .build(); + } +} diff --git a/src/main/java/com/chooz/image/presentation/ImageController.java b/src/main/java/com/chooz/image/presentation/ImageController.java new file mode 100644 index 00000000..48b8725a --- /dev/null +++ b/src/main/java/com/chooz/image/presentation/ImageController.java @@ -0,0 +1,26 @@ +package com.chooz.image.presentation; + +import com.chooz.image.application.ImageService; +import com.chooz.image.presentation.dto.PresignedUrlRequest; +import com.chooz.image.presentation.dto.PresignedUrlResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/image") +public class ImageController { + + private final ImageService imageService; + + @PostMapping(value = "/upload") + public ResponseEntity createPresignedUrl(@Valid @RequestBody PresignedUrlRequest request) { + PresignedUrlResponse response = imageService.getPresignedUrl(request); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileDto.java b/src/main/java/com/chooz/image/presentation/dto/ImageFileDto.java similarity index 75% rename from src/main/java/com/swyp8team2/image/presentation/dto/ImageFileDto.java rename to src/main/java/com/chooz/image/presentation/dto/ImageFileDto.java index 58ef06e5..5962cc64 100644 --- a/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileDto.java +++ b/src/main/java/com/chooz/image/presentation/dto/ImageFileDto.java @@ -1,4 +1,4 @@ -package com.swyp8team2.image.presentation.dto; +package com.chooz.image.presentation.dto; public record ImageFileDto(String originFileName, String imageUrl, diff --git a/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileResponse.java b/src/main/java/com/chooz/image/presentation/dto/ImageFileResponse.java similarity index 64% rename from src/main/java/com/swyp8team2/image/presentation/dto/ImageFileResponse.java rename to src/main/java/com/chooz/image/presentation/dto/ImageFileResponse.java index 1cae47a5..eac48f09 100644 --- a/src/main/java/com/swyp8team2/image/presentation/dto/ImageFileResponse.java +++ b/src/main/java/com/chooz/image/presentation/dto/ImageFileResponse.java @@ -1,4 +1,4 @@ -package com.swyp8team2.image.presentation.dto; +package com.chooz.image.presentation.dto; import java.util.List; diff --git a/src/main/java/com/chooz/image/presentation/dto/PreSignedResponse.java b/src/main/java/com/chooz/image/presentation/dto/PreSignedResponse.java new file mode 100644 index 00000000..66edaabd --- /dev/null +++ b/src/main/java/com/chooz/image/presentation/dto/PreSignedResponse.java @@ -0,0 +1,6 @@ +package com.chooz.image.presentation.dto; + +public record PreSignedResponse( + String preSignedUrl, + String fileName +) {} diff --git a/src/main/java/com/chooz/image/presentation/dto/PresignedUrlRequest.java b/src/main/java/com/chooz/image/presentation/dto/PresignedUrlRequest.java new file mode 100644 index 00000000..14452c2f --- /dev/null +++ b/src/main/java/com/chooz/image/presentation/dto/PresignedUrlRequest.java @@ -0,0 +1,10 @@ +package com.chooz.image.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record PresignedUrlRequest( + @NotNull Long contentLength, + @NotBlank String contentType +) { +} diff --git a/src/main/java/com/chooz/image/presentation/dto/PresignedUrlResponse.java b/src/main/java/com/chooz/image/presentation/dto/PresignedUrlResponse.java new file mode 100644 index 00000000..399c6006 --- /dev/null +++ b/src/main/java/com/chooz/image/presentation/dto/PresignedUrlResponse.java @@ -0,0 +1,8 @@ +package com.chooz.image.presentation.dto; + +public record PresignedUrlResponse( + String signedUploadPutUrl, + String signedGetUrl, + String assetUrl +) { +} diff --git a/src/main/java/com/chooz/notification/application/NotificationContentAssembler.java b/src/main/java/com/chooz/notification/application/NotificationContentAssembler.java new file mode 100644 index 00000000..9069fe8a --- /dev/null +++ b/src/main/java/com/chooz/notification/application/NotificationContentAssembler.java @@ -0,0 +1,96 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.application.dto.TargetPostDto; +import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.domain.NotificationType; +import com.chooz.notification.domain.Target; +import com.chooz.notification.domain.TargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class NotificationContentAssembler { + + private final NotificationService notificationService; + private final NotificationMessageRenderer renderer; + + public NotificationContent forCommentLiked(Long commentId, Long likerId) { + TargetUserDto commentAuthorDto = notificationService.findUserByCommentId(commentId); + TargetUserDto targetUserDto = notificationService.findUserById(likerId); + TargetPostDto targetPostDto = notificationService.findPostByCommentId(commentId); + var vars = Map.of("actorName", targetUserDto.nickname()); + var renderedMessage = renderer.render(NotificationType.COMMENT_LIKED.code(), vars); + return new NotificationContent( + commentAuthorDto.id(), + targetUserDto.id(), + renderedMessage.title(), + renderedMessage.content(), + targetUserDto.profileUrl(), + targetPostDto.imageUrl(), + List.of(Target.of(targetPostDto.id(), TargetType.POST), + Target.of(commentId, TargetType.COMMENT) + ) + ); + } + public NotificationContent forVoted(Long postId, Long voterId) { + TargetUserDto postAuthorDto = notificationService.findUserByPostId(postId); + TargetUserDto targetUserDto = notificationService.findUserById(voterId); + TargetPostDto targetPostDto = notificationService.findPostById(postId); + var vars = Map.of( + "actorName", targetUserDto.nickname(), + "postTitle", targetPostDto.title() + ); + var renderedMessage = renderer.render(NotificationType.POST_VOTED.code(), vars); + return new NotificationContent( + postAuthorDto.id(), + targetUserDto.id(), + renderedMessage.title(), + renderedMessage.content(), + targetUserDto.profileUrl(), + targetPostDto.imageUrl(), + List.of(Target.of(targetPostDto.id(), TargetType.POST)) + ); + } + public NotificationContent forMyPostClosed(Long postId, Long userId) { + TargetUserDto postAuthorDto = notificationService.findUserById(userId); + TargetPostDto targetPostDto = notificationService.findPostById(postId); + var vars = Map.of( + "postTitle", targetPostDto.title() + ); + var renderedMessage = renderer.render(NotificationType.MY_POST_CLOSED.code(), vars); + return new NotificationContent( + postAuthorDto.id(), + postAuthorDto.id(), + renderedMessage.title(), + renderedMessage.content(), + postAuthorDto.profileUrl(), + targetPostDto.imageUrl(), + List.of(Target.of(targetPostDto.id(), TargetType.POST)) + ); + } + public List forPostClosed(Long postId, Long userId) { + TargetUserDto postAuthorDto = notificationService.findUserById(userId); + List receiverUserDtos = notificationService.findVoteUsersByPostId(postId); + TargetPostDto targetPostDto = notificationService.findPostById(postId); + var vars = Map.of( + "postTitle", targetPostDto.title() + ); + var renderedMessage = renderer.render(NotificationType.POST_CLOSED.code(), vars); + return receiverUserDtos.stream().map(receiver -> + new NotificationContent( + receiver.id(), + postAuthorDto.id(), + renderedMessage.title(), + renderedMessage.content(), + postAuthorDto.profileUrl(), + targetPostDto.imageUrl(), + List.of(Target.of(targetPostDto.id(), TargetType.POST)) + )).collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/chooz/notification/application/NotificationMessageRenderer.java b/src/main/java/com/chooz/notification/application/NotificationMessageRenderer.java new file mode 100644 index 00000000..18378f13 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/NotificationMessageRenderer.java @@ -0,0 +1,9 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.dto.RenderedMessage; + +import java.util.Map; + +public interface NotificationMessageRenderer { + RenderedMessage render(String type, Map vars); +} diff --git a/src/main/java/com/chooz/notification/application/NotificationService.java b/src/main/java/com/chooz/notification/application/NotificationService.java new file mode 100644 index 00000000..d868109b --- /dev/null +++ b/src/main/java/com/chooz/notification/application/NotificationService.java @@ -0,0 +1,60 @@ +package com.chooz.notification.application; + +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.notification.application.dto.TargetPostDto; +import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.application.service.NotificationCommandService; +import com.chooz.notification.application.service.NotificationQueryService; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.TargetType; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; +import com.chooz.notification.presentation.dto.NotificationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationQueryService notificationQueryService; + private final NotificationCommandService notificationCommandService; + + public CursorBasePaginatedResponse findNotifications(Long userId, Long cursor, int size) { + return notificationQueryService.findNotifications(userId, cursor, size); + } + public TargetUserDto findUserByCommentId(Long commentId) { + return notificationQueryService.findUserByCommentId(commentId); + } + public TargetUserDto findUserById(Long userId) { + return notificationQueryService.findUserById(userId); + } + public TargetPostDto findPostByCommentId(Long commentId) { + return notificationQueryService.findPostByCommentId(commentId); + } + public TargetUserDto findUserByPostId(Long postId) { + return notificationQueryService.findUserByPostId(postId); + } + public TargetPostDto findPostById(Long postId) { + return notificationQueryService.findPostById(postId); + } + public Notification create(Notification notification) { + return notificationCommandService.create(notification); + } + public void createAll(List notifications) { + notificationCommandService.createAll(notifications); + } + public List findVoteUsersByPostId(Long postId) { + return notificationQueryService.findVoteUsersByPostId(postId); + } + public void markRead(Long notificationId) { + notificationCommandService.markRead(notificationId); + } + public NotificationPresentResponse present(Long userId) { + return notificationQueryService.present(userId); + } + public List findByTargetIdAndType(Long id, TargetType targetType){ + return notificationQueryService.findByTargetIdAndType(id, targetType); + } +} diff --git a/src/main/java/com/chooz/notification/application/dto/NotificationContent.java b/src/main/java/com/chooz/notification/application/dto/NotificationContent.java new file mode 100644 index 00000000..bf494560 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/NotificationContent.java @@ -0,0 +1,35 @@ +package com.chooz.notification.application.dto; + +import com.chooz.notification.domain.Target; + +import java.util.List; + +public record NotificationContent ( + Long receiverId, + Long actorId, + String title, + String content, + String profileUrl, + String imageUrl, + List targets +){ + public static NotificationContent of( + Long receiverId, + Long actorId, + String title, + String content, + String profileUrl, + String imageUrl, + List targets + ) { + return new NotificationContent( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ); + } +} diff --git a/src/main/java/com/chooz/notification/application/dto/RenderedMessage.java b/src/main/java/com/chooz/notification/application/dto/RenderedMessage.java new file mode 100644 index 00000000..91019993 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/RenderedMessage.java @@ -0,0 +1,4 @@ +package com.chooz.notification.application.dto; + +public record RenderedMessage(String title, String content) { +} diff --git a/src/main/java/com/chooz/notification/application/dto/TargetPostDto.java b/src/main/java/com/chooz/notification/application/dto/TargetPostDto.java new file mode 100644 index 00000000..fb4b256b --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/TargetPostDto.java @@ -0,0 +1,11 @@ +package com.chooz.notification.application.dto; + + +import com.querydsl.core.annotations.QueryProjection; + +@QueryProjection +public record TargetPostDto( + Long id, + String title, + String imageUrl +) {} diff --git a/src/main/java/com/chooz/notification/application/dto/TargetUserDto.java b/src/main/java/com/chooz/notification/application/dto/TargetUserDto.java new file mode 100644 index 00000000..285c4c6c --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/TargetUserDto.java @@ -0,0 +1,10 @@ +package com.chooz.notification.application.dto; + +import com.querydsl.core.annotations.QueryProjection; + +@QueryProjection +public record TargetUserDto( + Long id, + String nickname, + String profileUrl +) {} diff --git a/src/main/java/com/chooz/notification/application/listener/CommentLikeNotificationListener.java b/src/main/java/com/chooz/notification/application/listener/CommentLikeNotificationListener.java new file mode 100644 index 00000000..f3ab5ecf --- /dev/null +++ b/src/main/java/com/chooz/notification/application/listener/CommentLikeNotificationListener.java @@ -0,0 +1,33 @@ +package com.chooz.notification.application.listener; + +import com.chooz.commentLike.domain.event.CommentLikedNotificationEvent; +import com.chooz.notification.application.NotificationContentAssembler; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class CommentLikeNotificationListener { + + private final NotificationService notificationService; + private final NotificationContentAssembler notificationContentAssembler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCommentLiked(CommentLikedNotificationEvent commentLikedNotificationEvent) { + NotificationContent notificationContent = notificationContentAssembler.forCommentLiked( + commentLikedNotificationEvent.commentId(), + commentLikedNotificationEvent.likerId() + ); + Notification.create( + NotificationType.COMMENT_LIKED, + commentLikedNotificationEvent.eventAt(), + notificationContent + ).ifPresent(notificationService::create); + } +} diff --git a/src/main/java/com/chooz/notification/application/listener/MyPostClosedNotificationListener.java b/src/main/java/com/chooz/notification/application/listener/MyPostClosedNotificationListener.java new file mode 100644 index 00000000..cd2068f9 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/listener/MyPostClosedNotificationListener.java @@ -0,0 +1,36 @@ +package com.chooz.notification.application.listener; + +import com.chooz.notification.application.NotificationContentAssembler; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import com.chooz.post.application.dto.PostClosedNotificationEvent; +import com.chooz.post.domain.CloseType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class MyPostClosedNotificationListener { + + private final NotificationService notificationService; + private final NotificationContentAssembler notificationContentAssembler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onMyPostClosed(PostClosedNotificationEvent postClosedNotificationEvent) { + NotificationContent notificationContent = notificationContentAssembler.forMyPostClosed( + postClosedNotificationEvent.postId(), + postClosedNotificationEvent.userId() + ); + if(!postClosedNotificationEvent.closeType().equals(CloseType.SELF)){ + Notification.create( + NotificationType.MY_POST_CLOSED, + postClosedNotificationEvent.eventAt(), + notificationContent + ).ifPresent(notificationService::create); + } + } +} diff --git a/src/main/java/com/chooz/notification/application/listener/NotificationInvalidListener.java b/src/main/java/com/chooz/notification/application/listener/NotificationInvalidListener.java new file mode 100644 index 00000000..0a481e12 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/listener/NotificationInvalidListener.java @@ -0,0 +1,23 @@ +package com.chooz.notification.application.listener; + +import com.chooz.common.event.DeleteEvent; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.TargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class NotificationInvalidListener { + + private final NotificationService notificationService; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void inValid(DeleteEvent deleteEvent) { + notificationService.findByTargetIdAndType(deleteEvent.id(), TargetType.valueOf(deleteEvent.domain())) + .forEach(Notification::invalidate); + } +} diff --git a/src/main/java/com/chooz/notification/application/listener/PostClosedNotificationListener.java b/src/main/java/com/chooz/notification/application/listener/PostClosedNotificationListener.java new file mode 100644 index 00000000..bff57ea8 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/listener/PostClosedNotificationListener.java @@ -0,0 +1,39 @@ +package com.chooz.notification.application.listener; + +import com.chooz.notification.application.NotificationContentAssembler; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import com.chooz.post.application.dto.PostClosedNotificationEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class PostClosedNotificationListener { + + private final NotificationService notificationService; + private final NotificationContentAssembler notificationContentAssembler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPostClosed(PostClosedNotificationEvent postClosedNotificationEvent) { + List notificationContents = notificationContentAssembler.forPostClosed( + postClosedNotificationEvent.postId(), + postClosedNotificationEvent.userId() + ); + List notifications = new ArrayList<>(); + notificationContents.forEach(notificationContent -> + Notification.create( + NotificationType.POST_CLOSED, + postClosedNotificationEvent.eventAt(), + notificationContent + ).ifPresent(notifications::add)); + notificationService.createAll(notifications); + } +} diff --git a/src/main/java/com/chooz/notification/application/listener/VotedNotificationListener.java b/src/main/java/com/chooz/notification/application/listener/VotedNotificationListener.java new file mode 100644 index 00000000..8e9757f8 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/listener/VotedNotificationListener.java @@ -0,0 +1,33 @@ +package com.chooz.notification.application.listener; + +import com.chooz.notification.application.NotificationContentAssembler; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import com.chooz.vote.application.VotedNotificationEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class VotedNotificationListener { + + private final NotificationService notificationService; + private final NotificationContentAssembler notificationContentAssembler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onVoted(VotedNotificationEvent votedNotificationEvent) { + NotificationContent notificationContent = notificationContentAssembler.forVoted( + votedNotificationEvent.postId(), + votedNotificationEvent.voterId() + ); + Notification.create( + NotificationType.POST_VOTED, + votedNotificationEvent.eventAt(), + notificationContent + ).ifPresent(notificationService::create); + } +} diff --git a/src/main/java/com/chooz/notification/application/service/NotificationCommandService.java b/src/main/java/com/chooz/notification/application/service/NotificationCommandService.java new file mode 100644 index 00000000..4f503a89 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/service/NotificationCommandService.java @@ -0,0 +1,55 @@ +package com.chooz.notification.application.service; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class NotificationCommandService { + + private final NotificationRepository notificationRepository; + private final NotificationQueryRepository notificationQueryRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Notification create(Notification notification) { + return notificationQueryRepository.existsByDedupKey(notification.getReceiverId(), notification.getDedupKey()) + ? null + : notificationRepository.save(notification); + } + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void createAll(List notifications) { + List existsNotifications = notificationQueryRepository.findNotificationsByDedupKey(notifications); + Set existingPairs = getExistingPairs(existsNotifications); + List toSave = getNotificationsNotDuplicated(notifications, existingPairs); + if (!toSave.isEmpty()) { + notificationRepository.saveAll(toSave); + } + } + private Set getExistingPairs(List existsNotifications) { + return existsNotifications.stream() + .map(n -> n.getReceiverId() + "|" + n.getDedupKey()) + .collect(Collectors.toSet()); + } + private List getNotificationsNotDuplicated(List notifications, Set existingPairs) { + return notifications.stream() + .filter(n -> !existingPairs.contains(n.getReceiverId() + "|" + n.getDedupKey())) + .toList(); + } + @Transactional + public void markRead(Long notificationId){ + Notification notification = notificationRepository.findNotificationById(notificationId) + .orElseThrow(() -> new BadRequestException(ErrorCode.NOTIFICATION_NOT_FOUND)); + notification.markRead(); + } +} diff --git a/src/main/java/com/chooz/notification/application/service/NotificationQueryService.java b/src/main/java/com/chooz/notification/application/service/NotificationQueryService.java new file mode 100644 index 00000000..e68243f4 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/service/NotificationQueryService.java @@ -0,0 +1,64 @@ +package com.chooz.notification.application.service; + +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.notification.application.dto.TargetPostDto; +import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.NotificationRepository; +import com.chooz.notification.domain.TargetType; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; +import com.chooz.notification.presentation.dto.NotificationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class NotificationQueryService { + + private final NotificationQueryRepository notificationQueryRepository; + private final NotificationRepository notificationRepository; + + public CursorBasePaginatedResponse findNotifications(Long userId, Long cursor, int size) { + Slice notifications = notificationQueryRepository.findNotifications(userId, cursor, PageRequest.ofSize(size)); + return CursorBasePaginatedResponse.of(notifications.map(NotificationResponse::of)); + } + public TargetUserDto findUserByCommentId(Long commentId) { + return notificationQueryRepository.findUserByCommentId(commentId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + } + public TargetUserDto findUserById(Long userId) { + return notificationQueryRepository.findUserById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + } + public TargetPostDto findPostByCommentId(Long commentId) { + return notificationQueryRepository.findPostByCommentId(commentId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + } + public TargetUserDto findUserByPostId(Long postId) { + return notificationQueryRepository.findUserByPostId(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + } + public TargetPostDto findPostById(Long postId) { + return notificationQueryRepository.findPostById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + } + public List findVoteUsersByPostId(Long postId) { + return notificationQueryRepository.findVoteUsersByPostId(postId); + } + public NotificationPresentResponse present(Long userId) { + return NotificationPresentResponse.of(notificationRepository.existsByReceiverIdAndIsReadFalseAndDeletedFalse(userId)); + } + public List findByTargetIdAndType(Long id, TargetType targetType) { + return notificationRepository.findByTargetIdAndType(id, targetType); + } +} diff --git a/src/main/java/com/chooz/notification/application/web/dto/NotificationDto.java b/src/main/java/com/chooz/notification/application/web/dto/NotificationDto.java new file mode 100644 index 00000000..25f01696 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/web/dto/NotificationDto.java @@ -0,0 +1,12 @@ +package com.chooz.notification.application.web.dto; + + +import com.querydsl.core.annotations.QueryProjection; + +import java.util.List; + +@QueryProjection +public record NotificationDto( + NotificationRowDto notificationRowDto, + List targets +) {} diff --git a/src/main/java/com/chooz/notification/application/web/dto/NotificationRowDto.java b/src/main/java/com/chooz/notification/application/web/dto/NotificationRowDto.java new file mode 100644 index 00000000..b09eb410 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/web/dto/NotificationRowDto.java @@ -0,0 +1,20 @@ +package com.chooz.notification.application.web.dto; + + +import com.chooz.notification.domain.NotificationType; +import com.querydsl.core.annotations.QueryProjection; + +import java.time.LocalDateTime; + +@QueryProjection +public record NotificationRowDto( + Long id, + NotificationType notificationType, + String profileUrl, + String title, + String content, + String imageUrl, + boolean isValid, + boolean isRead, + LocalDateTime eventAt +) {} diff --git a/src/main/java/com/chooz/notification/application/web/dto/TargetDto.java b/src/main/java/com/chooz/notification/application/web/dto/TargetDto.java new file mode 100644 index 00000000..7d4d2181 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/web/dto/TargetDto.java @@ -0,0 +1,11 @@ +package com.chooz.notification.application.web.dto; + + +import com.chooz.notification.domain.TargetType; +import com.querydsl.core.annotations.QueryProjection; + +@QueryProjection +public record TargetDto( + Long id, + TargetType type +) {} diff --git a/src/main/java/com/chooz/notification/domain/Notification.java b/src/main/java/com/chooz/notification/domain/Notification.java new file mode 100644 index 00000000..47d96ca7 --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/Notification.java @@ -0,0 +1,125 @@ +package com.chooz.notification.domain; + +import com.chooz.common.domain.BaseEntity; +import com.chooz.notification.application.dto.NotificationContent; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +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.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +@Getter +@Entity +@Table(name = "notifications") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "receiver_id", nullable = false) + private Long receiverId; + + @Column(name = "profile_url", nullable = false) + private String profileUrl; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", nullable = false) + private String content; + + @Builder.Default + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable( + name = "notification_targets", + joinColumns = @JoinColumn(name = "notification_id") + ) + private List targets = new ArrayList<>(); + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false, length = 50) + private NotificationType notificationType; + + @Column(name = "image_url", nullable = false) + private String imageUrl; + + @Column(name = "dedupKey", nullable = false) + private String dedupKey; + + @Column(name = "is_valid", nullable = false) + private boolean isValid; + + @Column(name = "is_read", nullable = false) + private boolean isRead; + + @Column(name = "event_at", nullable = false) + private LocalDateTime eventAt; + + public static Optional create( + NotificationType notificationType, + LocalDateTime eventAt, + NotificationContent notificationContent + ) { + if (checkMine(notificationContent.actorId(), notificationContent.receiverId(), notificationType)) { + return Optional.empty(); + } + return Optional.of(Notification.builder() + .receiverId(notificationContent.receiverId()) + .profileUrl(notificationContent.profileUrl()) + .title(notificationContent.title()) + .content(notificationContent.content()) + .targets(List.copyOf(notificationContent.targets())) + .notificationType(notificationType) + .imageUrl(notificationContent.imageUrl()) + .dedupKey(makeDedupKey(notificationType, notificationContent.actorId(), notificationContent.targets())) + .isValid(true) + .isRead(false) + .eventAt(eventAt) + .build()); + } + private static boolean checkMine(Long actorId, Long receiverId, NotificationType notificationType) { + return actorId != null && actorId.equals(receiverId) && !NotificationType.isMyPostClosed(notificationType); + } + public static String makeDedupKey(NotificationType notificationType, Long actorId, List targets) { + StringBuilder key = new StringBuilder(100) + .append(actorId).append('|') + .append(notificationType.name()); + targets = targets.stream().sorted(Comparator.comparing(Target::getType)).toList(); + for (Target target : targets) { + key.append('|').append(target.getType()).append(':').append(target.getId()); + } + return key.toString(); + } + public void markRead() { + if (!isRead) { + this.isRead = true; + } + } + public void invalidate() { + if (isValid) { + this.isValid = false; + } + } +} diff --git a/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java b/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java new file mode 100644 index 00000000..ba4851cf --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java @@ -0,0 +1,22 @@ +package com.chooz.notification.domain; + +import com.chooz.notification.application.dto.TargetPostDto; +import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.application.web.dto.NotificationDto; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import java.util.List; +import java.util.Optional; + +public interface NotificationQueryRepository { + Slice findNotifications(Long userId, Long cursor, Pageable pageable); + Optional findPostByCommentId(Long commentId); + Optional findUserByCommentId(Long commentId); + Optional findUserById(Long userId); + Optional findUserByPostId(Long postId); + Optional findPostById(Long postId); + boolean existsByDedupKey(Long ReceiverId, String dedupKey); + List findNotificationsByDedupKey(List notifications); + List findVoteUsersByPostId(Long postId); +} diff --git a/src/main/java/com/chooz/notification/domain/NotificationRepository.java b/src/main/java/com/chooz/notification/domain/NotificationRepository.java new file mode 100644 index 00000000..968ca92d --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/NotificationRepository.java @@ -0,0 +1,12 @@ +package com.chooz.notification.domain; + +import java.util.List; +import java.util.Optional; + +public interface NotificationRepository { + Notification save(Notification notification); + void saveAll(List notifications); + Optional findNotificationById(Long id); + boolean existsByReceiverIdAndIsReadFalseAndDeletedFalse(Long userId); + List findByTargetIdAndType(Long targetId, TargetType targetType); +} diff --git a/src/main/java/com/chooz/notification/domain/NotificationType.java b/src/main/java/com/chooz/notification/domain/NotificationType.java new file mode 100644 index 00000000..8973349e --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/NotificationType.java @@ -0,0 +1,16 @@ +package com.chooz.notification.domain; + +public enum NotificationType { + POST_CLOSED("NOTI.POST.CLOSED"), + MY_POST_CLOSED("NOTI.MY.POST.CLOSED"), + COMMENT_LIKED("NOTI.COMMENT.LIKED"), + POST_VOTED("NOTI.POST.VOTED"); + + private final String code; + NotificationType(String code) {this.code = code;} + public String code() {return code;} + + public static boolean isMyPostClosed(NotificationType notificationType) { + return NotificationType.valueOf(notificationType.name()).equals(MY_POST_CLOSED); + } +} diff --git a/src/main/java/com/chooz/notification/domain/Receiver.java b/src/main/java/com/chooz/notification/domain/Receiver.java new file mode 100644 index 00000000..94d8401a --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/Receiver.java @@ -0,0 +1,20 @@ +package com.chooz.notification.domain; + + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Receiver { + @Column(name = "receiver_id", nullable = false) + private Long id; + public static Receiver of(Long id){ + return new Receiver(id); + } +} diff --git a/src/main/java/com/chooz/notification/domain/Target.java b/src/main/java/com/chooz/notification/domain/Target.java new file mode 100644 index 00000000..ff69e094 --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/Target.java @@ -0,0 +1,28 @@ +package com.chooz.notification.domain; + + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Target { + + @Column(name = "target_id", nullable = false) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false) + private TargetType type; + + public static Target of(Long id, TargetType type) { + return new Target(id, type); + } +} diff --git a/src/main/java/com/chooz/notification/domain/TargetType.java b/src/main/java/com/chooz/notification/domain/TargetType.java new file mode 100644 index 00000000..d23b7bfd --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/TargetType.java @@ -0,0 +1,7 @@ +package com.chooz.notification.domain; + +public enum TargetType { + POST, + COMMENT, + COMMENT_LIKE +} diff --git a/src/main/java/com/chooz/notification/infrastructure/MessageSourceConfig.java b/src/main/java/com/chooz/notification/infrastructure/MessageSourceConfig.java new file mode 100644 index 00000000..d2f15cab --- /dev/null +++ b/src/main/java/com/chooz/notification/infrastructure/MessageSourceConfig.java @@ -0,0 +1,18 @@ +package com.chooz.notification.infrastructure; + +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; + +@Configuration +public class MessageSourceConfig { + + @Bean(name = "messageSource") + public MessageSource notificationMessageSource() { + ReloadableResourceBundleMessageSource ms = new ReloadableResourceBundleMessageSource(); + ms.setBasenames("classpath:notification/messages"); + ms.setDefaultEncoding("UTF-8"); + return ms; + } +} diff --git a/src/main/java/com/chooz/notification/infrastructure/MessageSourceNotificationRenderer.java b/src/main/java/com/chooz/notification/infrastructure/MessageSourceNotificationRenderer.java new file mode 100644 index 00000000..092b45ff --- /dev/null +++ b/src/main/java/com/chooz/notification/infrastructure/MessageSourceNotificationRenderer.java @@ -0,0 +1,43 @@ +package com.chooz.notification.infrastructure; + +import com.chooz.common.util.Validator; +import com.chooz.notification.application.NotificationMessageRenderer; +import com.chooz.notification.application.dto.RenderedMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Component; + +import java.util.Locale; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class MessageSourceNotificationRenderer implements NotificationMessageRenderer { + + private final MessageSource notificationMessageSource; + private static final Locale DEFAULT_LOCALE = Locale.KOREAN; + + @Override + public RenderedMessage render(String code, Map vars) { + String titleKey = code + ".title"; + String contentKey = code + ".content"; + + Object[] args = argsFrom(vars); + String title = getMessage(titleKey, args); + String content = getMessage(contentKey , args); + Validator.validateEmptyString(title); + Validator.validateEmptyString(content); + return new RenderedMessage(title, content); + } + + private String getMessage(String key, Object[] args) { + return notificationMessageSource.getMessage(key, args, DEFAULT_LOCALE); + } + + private Object[] argsFrom(Map vars) { + return new Object[] { + vars.get("actorName"), + vars.get("postTitle"), + }; + } +} diff --git a/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java b/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java new file mode 100644 index 00000000..42d24a75 --- /dev/null +++ b/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java @@ -0,0 +1,26 @@ +package com.chooz.notification.persistence; + +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.TargetType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NotificationJpaRepository extends JpaRepository { + boolean existsByReceiverIdAndIsReadFalseAndDeletedFalse(Long userId); + @Query(""" + SELECT distinct n + FROM Notification n + join n.targets t + where t.id = :targetId + and t.type = :targetType + and n.isValid = true + order by n.id desc + """ + ) + List findByTargetIdAndType(@Param("targetId") Long targetId, @Param("targetType") TargetType targetType); +} diff --git a/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java b/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java new file mode 100644 index 00000000..b7f84d30 --- /dev/null +++ b/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java @@ -0,0 +1,164 @@ +package com.chooz.notification.persistence; + +import com.chooz.notification.application.dto.QTargetPostDto; +import com.chooz.notification.application.dto.QTargetUserDto; +import com.chooz.notification.application.dto.TargetPostDto; +import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.application.web.dto.NotificationRowDto; +import com.chooz.notification.application.web.dto.QNotificationRowDto; +import com.chooz.notification.application.web.dto.QTargetDto; +import com.chooz.notification.application.web.dto.TargetDto; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.QTarget; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static com.chooz.comment.domain.QComment.comment; +import static com.chooz.notification.domain.QNotification.notification; +import static com.chooz.post.domain.QPost.post; +import static com.chooz.user.domain.QUser.user; +import static com.chooz.vote.domain.QVote.vote; + +@Repository +@RequiredArgsConstructor +public class NotificationQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + public Slice findNotifications(Long userId, Long cursor, Pageable pageable) { + List notificationRows = queryFactory + .select(new QNotificationRowDto( + notification.id, + notification.notificationType, + notification.profileUrl, + notification.title, + notification.content, + notification.imageUrl, + notification.isValid, + notification.isRead, + notification.eventAt + ) + ) + .from(notification) + .where( + notification.receiverId.eq(userId), + notification.isValid.eq(true), + cursor != null ? notification.id.lt(cursor) : null + ) + .orderBy(notification.id.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + if(notificationRows.isEmpty()) { + return new SliceImpl<>(List.of(), pageable, false); + } + List notifications = findNotificationsWithTarget(notificationRows); + + boolean hasNext = notifications.size() > pageable.getPageSize(); + if (hasNext) notifications.removeLast(); + return new SliceImpl<>(notifications, pageable, hasNext); + } + private List findNotificationsWithTarget(List notificationRows) { + QTarget target = QTarget.target; + List ids = notificationRows.stream().map(NotificationRowDto::id).toList(); + Map> targetsByNotificationId = queryFactory + .from(notification) + .join(notification.targets, target) + .where(notification.id.in(ids)) + .transform(com.querydsl.core.group.GroupBy.groupBy(notification.id).as( + com.querydsl.core.group.GroupBy.list(new QTargetDto(target.id, target.type)) + )); + return notificationRows.stream().map( + row -> new NotificationDto( + row, + targetsByNotificationId.getOrDefault(row.id(), List.of()) + )).collect(Collectors.toCollection(ArrayList::new)); + } + + public Optional findPostByCommentId(Long commentId) { + return Optional.ofNullable( + queryFactory.select(new QTargetPostDto(post.id, post.title, post.imageUrl)) + .from(comment) + .join(post).on(post.id.eq(comment.postId), post.deleted.eq(false)) + .where(comment.id.eq(commentId)) + .limit(1) + .fetchFirst()); + } + public Optional findUserByCommentId(Long commentId) { + return Optional.ofNullable( + queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) + .from(comment) + .join(user).on(user.id.eq(comment.userId)) + .where(comment.id.eq(commentId)) + .limit(1) + .fetchFirst()); + } + public Optional findUserById(Long userId) { + return Optional.ofNullable( + queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) + .from(user) + .where(user.id.eq(userId)) + .limit(1) + .fetchFirst()); + } + public Optional findUserByPostId(Long postId) { + return Optional.ofNullable( + queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) + .from(user) + .join(post).on(user.id.eq(post.userId), post.deleted.eq(false)) + .where(post.id.eq(postId)) + .limit(1) + .fetchFirst()); + } + public Optional findPostById(Long postId) { + return Optional.ofNullable( + queryFactory.select(new QTargetPostDto(post.id, post.title, post.imageUrl)) + .from(post) + .where( + post.id.eq(postId), + post.deleted.eq(false) + ) + .limit(1) + .fetchFirst()); + } + public boolean existsByDedupKey(Long receiverId, String dedupkey) { + Integer one = queryFactory.selectOne() + .from(notification) + .where( + notification.receiverId.eq(receiverId), + notification.dedupKey.eq(dedupkey) + ).fetchFirst(); + return one != null; + } + public List findNotificationsByDedupKey(List notifications) { + BooleanBuilder builder = new BooleanBuilder(); + for (Notification n : notifications) { + builder.or( + notification.receiverId.eq(n.getReceiverId()) + .and(notification.dedupKey.eq(n.getDedupKey())) + ); + } + return queryFactory.selectFrom(notification) + .where(builder) + .fetch(); + } + public List findVoteUsersByPostId(Long postId) { + return queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) + .from(user) + .join(vote).on(user.id.eq(vote.userId), vote.deleted.eq(false)) + .join(post).on(post.id.eq(vote.postId), post.deleted.eq(false)) + .fetch(); + } + +} diff --git a/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java b/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java new file mode 100644 index 00000000..dcd54feb --- /dev/null +++ b/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java @@ -0,0 +1,66 @@ +package com.chooz.notification.persistence; + +import com.chooz.notification.application.dto.TargetPostDto; +import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class NotificationQueryRepositoryImpl implements NotificationQueryRepository { + + private final NotificationQueryDslRepository notificationQueryDslRepository; + + @Override + public Slice findNotifications(Long userId, Long cursor, Pageable pageable) { + return notificationQueryDslRepository.findNotifications(userId, cursor, pageable); + } + + @Override + public Optional findPostByCommentId(Long commentId) { + return notificationQueryDslRepository.findPostByCommentId(commentId); + } + + @Override + public Optional findUserByCommentId(Long commentId) { + return notificationQueryDslRepository.findUserByCommentId(commentId); + } + + @Override + public Optional findUserById(Long userId) { + return notificationQueryDslRepository.findUserById(userId); + } + + @Override + public Optional findUserByPostId(Long postId) { + return notificationQueryDslRepository.findUserByPostId(postId); + } + + @Override + public Optional findPostById(Long postId) { + return notificationQueryDslRepository.findPostById(postId); + } + + @Override + public boolean existsByDedupKey(Long ReceiverId, String dedupKey) { + return notificationQueryDslRepository.existsByDedupKey(ReceiverId, dedupKey); + } + + @Override + public List findNotificationsByDedupKey(List notifications) { + return notificationQueryDslRepository.findNotificationsByDedupKey(notifications); + } + + @Override + public List findVoteUsersByPostId(Long postId) { + return notificationQueryDslRepository.findVoteUsersByPostId(postId); + } +} \ No newline at end of file diff --git a/src/main/java/com/chooz/notification/persistence/NotificationRepositoryImpl.java b/src/main/java/com/chooz/notification/persistence/NotificationRepositoryImpl.java new file mode 100644 index 00000000..9fcbbffc --- /dev/null +++ b/src/main/java/com/chooz/notification/persistence/NotificationRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.chooz.notification.persistence; + +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationRepository; +import com.chooz.notification.domain.TargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + + +@Repository +@RequiredArgsConstructor +public class NotificationRepositoryImpl implements NotificationRepository { + + private final NotificationJpaRepository notificationJpaRepository; + + @Override + public Notification save(Notification notification) { + return notificationJpaRepository.save(notification); + } + + @Override + public void saveAll(List notifications) { + notificationJpaRepository.saveAll(notifications); + } + + @Override + public Optional findNotificationById(Long id) { + return notificationJpaRepository.findById(id); + } + + @Override + public boolean existsByReceiverIdAndIsReadFalseAndDeletedFalse(Long userId) { + return notificationJpaRepository.existsByReceiverIdAndIsReadFalseAndDeletedFalse(userId); + } + + @Override + public List findByTargetIdAndType(Long targetId, TargetType targetType) { + return notificationJpaRepository.findByTargetIdAndType(targetId, targetType); + } +} \ No newline at end of file diff --git a/src/main/java/com/chooz/notification/presentation/NotificationController.java b/src/main/java/com/chooz/notification/presentation/NotificationController.java new file mode 100644 index 00000000..d821a1ab --- /dev/null +++ b/src/main/java/com/chooz/notification/presentation/NotificationController.java @@ -0,0 +1,46 @@ +package com.chooz.notification.presentation; + +import com.chooz.auth.domain.UserInfo; +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; +import com.chooz.notification.presentation.dto.NotificationResponse; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications") +public class NotificationController { + private final NotificationService notificationService; + + @GetMapping("") + public ResponseEntity> findNotifications( + @RequestParam(name = "cursor", required = false) @Min(0) Long cursor, + @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(notificationService.findNotifications(userInfo.userId(), cursor, size)); + } + @PatchMapping("/{notificationId}") + public void markRead( + @PathVariable("notificationId") Long notificationId + ) { + notificationService.markRead(notificationId); + ResponseEntity.ok().build(); + } + @GetMapping("/present") + public ResponseEntity present( + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(notificationService.present(userInfo.userId())); + } +} diff --git a/src/main/java/com/chooz/notification/presentation/dto/NotificationPresentResponse.java b/src/main/java/com/chooz/notification/presentation/dto/NotificationPresentResponse.java new file mode 100644 index 00000000..1e5cc816 --- /dev/null +++ b/src/main/java/com/chooz/notification/presentation/dto/NotificationPresentResponse.java @@ -0,0 +1,7 @@ +package com.chooz.notification.presentation.dto; + +public record NotificationPresentResponse(boolean present){ + public static NotificationPresentResponse of(boolean present) { + return new NotificationPresentResponse(present); + } +} diff --git a/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java b/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java new file mode 100644 index 00000000..b17bd975 --- /dev/null +++ b/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java @@ -0,0 +1,39 @@ +package com.chooz.notification.presentation.dto; + +import com.chooz.common.dto.CursorDto; +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationType; +import com.chooz.notification.domain.Target; + +import java.time.LocalDateTime; +import java.util.List; + +public record NotificationResponse ( + Long id, + NotificationType notificationType, + String profileUrl, + String title, + String content, + String imageUrl, + List targets, + boolean isValid, + boolean isRead, + LocalDateTime eventAt +)implements CursorDto{ + public static NotificationResponse of (NotificationDto notificationDto){ + return new NotificationResponse( + notificationDto.notificationRowDto().id(), + notificationDto.notificationRowDto().notificationType(), + notificationDto.notificationRowDto().profileUrl(), + notificationDto.notificationRowDto().title(), + notificationDto.notificationRowDto().content(), + notificationDto.notificationRowDto().imageUrl(), + List.copyOf(notificationDto.targets().stream().map(t -> Target.of(t.id(), t.type())).toList()), + notificationDto.notificationRowDto().isValid(), + notificationDto.notificationRowDto().isRead(), + notificationDto.notificationRowDto().eventAt() + ); + } + @Override + public long getId() { return this.id; } +} diff --git a/src/main/java/com/chooz/post/application/DateCloseScheduler.java b/src/main/java/com/chooz/post/application/DateCloseScheduler.java new file mode 100644 index 00000000..e3c88c88 --- /dev/null +++ b/src/main/java/com/chooz/post/application/DateCloseScheduler.java @@ -0,0 +1,42 @@ +package com.chooz.post.application; + +import com.chooz.common.event.EventPublisher; +import com.chooz.post.application.dto.PostClosedNotificationEvent; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DateCloseScheduler { + + private final PostRepository postRepository; + private final EventPublisher eventPublisher; + + @Transactional + @Scheduled(fixedDelay = 1000) + public void closePostsByDate() { + log.info("마감 μŠ€μΌ€μ€„λ§ μ‹œμž‘ | μ„œλ²„ μ‹œκ°„: {}", LocalDateTime.now()); + List postsNeedToClose = postRepository.findPostsNeedToClose(); + postsNeedToClose.forEach(Post::close); + postsNeedToClose.forEach( + post -> eventPublisher.publish(new PostClosedNotificationEvent( + post.getId(), + post.getUserId(), + post.getCloseOption().getCloseType(), + LocalDateTime.now() + ) + ) + ); + log.info("총 {}개 κ²Œμ‹œκΈ€ 마감", postsNeedToClose.size()); + } +} diff --git a/src/main/java/com/chooz/post/application/MyPagePostManager.java b/src/main/java/com/chooz/post/application/MyPagePostManager.java new file mode 100644 index 00000000..d32b994e --- /dev/null +++ b/src/main/java/com/chooz/post/application/MyPagePostManager.java @@ -0,0 +1,120 @@ +package com.chooz.post.application; + +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.post.application.dto.PollChoiceVoteInfo; +import com.chooz.post.application.dto.PostWithVoteCount; +import com.chooz.post.domain.PollChoiceRepository; +import com.chooz.post.domain.PostRepository; +import com.chooz.post.presentation.dto.MostVotedPollChoiceDto; +import com.chooz.post.presentation.dto.MyPagePostResponse; +import com.chooz.vote.application.RatioCalculator; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class MyPagePostManager { + + private final PostRepository postRepository; + private final PollChoiceRepository pollChoiceRepository; + private final RatioCalculator ratioCalculator; + + public CursorBasePaginatedResponse getUserPosts( + Long userId, + Long myPageUserId, + Long cursor, + Pageable pageable + ) { + Slice postSlice = postRepository.findPostsWithVoteCountByUserId( + userId, + myPageUserId, + cursor, + pageable + ); + + return getMyPageCursoredResponse(postSlice); + } + + public CursorBasePaginatedResponse getVotedPosts( + Long userId, + Long myPageUserId, + Long cursor, + Pageable pageable + ) { + Slice postSlice = postRepository.findVotedPostsWithVoteCount( + userId, + myPageUserId, + cursor, + pageable + ); + + return getMyPageCursoredResponse(postSlice); + } + + private CursorBasePaginatedResponse getMyPageCursoredResponse(Slice postSlice) { + if (postSlice.isEmpty()) { + return CursorBasePaginatedResponse.of(new SliceImpl<>( + List.of(), + postSlice.getPageable(), + false + )); + } + + List postIds = getPostIds(postSlice); + Map mostVotedPollChoiceByPostId = getMostVotedPollChoiceByPostId(postIds); + + List responses = getMyPagePostResponses(postSlice, mostVotedPollChoiceByPostId); + + return CursorBasePaginatedResponse.of(new SliceImpl<>( + responses, + postSlice.getPageable(), + postSlice.hasNext() + )); + } + + private Map getMostVotedPollChoiceByPostId(List postIds) { + List pollChoiceWithVoteInfo = pollChoiceRepository.findPollChoiceWithVoteInfo(postIds); + return pollChoiceWithVoteInfo.stream() + .collect(Collectors.groupingBy( + PollChoiceVoteInfo::postId, + Collectors.collectingAndThen( + Collectors.toList(), + choices -> choices.stream() + .max(Comparator.comparing(PollChoiceVoteInfo::voteCounts)) + .orElse(null) + ) + )); + } + + private List getMyPagePostResponses( + Slice postSlice, + Map mostVotedPollChoiceByPostId + ) { + return postSlice.getContent() + .stream() + .map(postWithVoteCount -> { + var pollChoiceVoteInfo = mostVotedPollChoiceByPostId.get(postWithVoteCount.post().getId()); + var mostVotedPollChoiceInfo = MostVotedPollChoiceDto.of( + pollChoiceVoteInfo, + ratioCalculator.calculate(postWithVoteCount.voteCount(), pollChoiceVoteInfo.voteCounts()) + ); + return MyPagePostResponse.of(postWithVoteCount, mostVotedPollChoiceInfo); + }) + .toList(); + } + + private List getPostIds(Slice postSlice) { + return postSlice.getContent() + .stream() + .map(postWithVoteCount -> postWithVoteCount.post().getId()) + .toList(); + } +} diff --git a/src/main/java/com/swyp8team2/post/application/PostImageNameGenerator.java b/src/main/java/com/chooz/post/application/PollChoiceNameGenerator.java similarity index 56% rename from src/main/java/com/swyp8team2/post/application/PostImageNameGenerator.java rename to src/main/java/com/chooz/post/application/PollChoiceNameGenerator.java index ab1edd99..7b677a42 100644 --- a/src/main/java/com/swyp8team2/post/application/PostImageNameGenerator.java +++ b/src/main/java/com/chooz/post/application/PollChoiceNameGenerator.java @@ -1,9 +1,9 @@ -package com.swyp8team2.post.application; +package com.chooz.post.application; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.InternalServerException; -public class PostImageNameGenerator { +public class PollChoiceNameGenerator { private int index = 0; private final String[] alphabets = new String[]{ @@ -14,7 +14,7 @@ public class PostImageNameGenerator { public String generate() { if (index >= alphabets.length) { - throw new InternalServerException(ErrorCode.POST_IMAGE_NAME_GENERATOR_INDEX_OUT_OF_BOUND); + throw new InternalServerException(ErrorCode.POLL_CHOICE_NAME_GENERATOR_INDEX_OUT_OF_BOUND); } return "λ½€λ˜" + alphabets[index++]; } diff --git a/src/main/java/com/chooz/post/application/PostCommandService.java b/src/main/java/com/chooz/post/application/PostCommandService.java new file mode 100644 index 00000000..9c0469c0 --- /dev/null +++ b/src/main/java/com/chooz/post/application/PostCommandService.java @@ -0,0 +1,128 @@ +package com.chooz.post.application; + +import com.chooz.common.event.DeleteEvent; +import com.chooz.common.event.EventPublisher; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.application.dto.PostClosedNotificationEvent; +import com.chooz.post.domain.CloseOption; +import com.chooz.post.domain.PollOption; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PollChoice; +import com.chooz.post.domain.PostRepository; +import com.chooz.post.presentation.dto.CreatePostRequest; +import com.chooz.post.presentation.dto.CreatePostResponse; +import com.chooz.post.presentation.dto.UpdatePostRequest; +import com.chooz.thumbnail.domain.Thumbnail; +import com.chooz.thumbnail.domain.ThumbnailRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +public class PostCommandService { + + private final PostRepository postRepository; + private final ShareUrlService shareUrlService; + private final ThumbnailRepository thumbnailRepository; + private final PostValidator postValidator; + private final EventPublisher eventPublisher; + + public CreatePostResponse create(Long userId, CreatePostRequest request) { + Post post = createPost(userId, request); + savePostThumbnail(post); + return new CreatePostResponse(post.getId(), post.getShareUrl()); + } + + private Post createPost(Long userId, CreatePostRequest request) { + List pollChoices = createPollChoices(request); + String shareUrl = shareUrlService.generateShareUrl(); + Post post = Post.create( + userId, + request.title(), + request.description(), + pollChoices.getFirst().getImageUrl(), + pollChoices, + shareUrl, + PollOption.create( + request.pollOption().pollType(), + request.pollOption().scope(), + request.pollOption().commentActive() + ), + CloseOption.create( + request.closeOption().closeType(), + request.closeOption().closedAt(), + request.closeOption().maxVoterCount() + ) + ); + return postRepository.save(post); + } + + private List createPollChoices(CreatePostRequest request) { + return request.pollChoices() + .stream() + .map(pollChoiceDto -> PollChoice.create( + pollChoiceDto.title(), pollChoiceDto.imageUrl() + )) + .collect(Collectors.toList()); + } + + private void savePostThumbnail(Post post) { + PollChoice thumbnailPollChoice = post.getPollChoices().getFirst(); + thumbnailRepository.save( + Thumbnail.create(post.getId(), thumbnailPollChoice.getId(), thumbnailPollChoice.getImageUrl()) + ); + } + + @Transactional + public void delete(Long userId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + post.delete(userId); + eventPublisher.publish(DeleteEvent.of(post.getId(), post.getClass().getSimpleName().toUpperCase())); + } + + @Transactional + public void close(Long userId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + post.closeByAuthor(userId); + eventPublisher.publish(new PostClosedNotificationEvent( + post.getId(), + post.getUserId(), + post.getCloseOption().getCloseType(), + LocalDateTime.now() + ) + ); + } + + @Transactional + public void update(Long userId, Long postId, UpdatePostRequest request) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + + postValidator.validateUpdate(post, userId, request); + + post.update( + userId, + request.title(), + request.description(), + PollOption.create( + request.pollOption().pollType(), + request.pollOption().scope(), + request.pollOption().commentActive() + ), + new CloseOption( + request.closeOption().closeType(), + request.closeOption().closedAt(), + request.closeOption().maxVoterCount() + ) + ); + } +} diff --git a/src/main/java/com/chooz/post/application/PostQueryService.java b/src/main/java/com/chooz/post/application/PostQueryService.java new file mode 100644 index 00000000..b68a19db --- /dev/null +++ b/src/main/java/com/chooz/post/application/PostQueryService.java @@ -0,0 +1,131 @@ +package com.chooz.post.application; + +import com.chooz.comment.domain.CommentRepository; +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.application.dto.FeedDto; +import com.chooz.post.domain.PollChoice; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.post.presentation.dto.AuthorDto; +import com.chooz.post.presentation.dto.FeedResponse; +import com.chooz.post.presentation.dto.MyPagePostResponse; +import com.chooz.post.presentation.dto.PollChoiceVoteResponse; +import com.chooz.post.presentation.dto.PostResponse; +import com.chooz.post.presentation.dto.UpdatePostResponse; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.domain.Vote; +import com.chooz.vote.domain.VoteRepository; +import jakarta.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PostQueryService { + + private final PostRepository postRepository; + private final UserRepository userRepository; + private final VoteRepository voteRepository; + private final CommentRepository commentRepository; + private final MyPagePostManager myPagePostManager; + + public PostResponse findByShareUrl(Long userId, String shareUrl) { + Post post = postRepository.findByShareUrlFetchPollChoices(shareUrl) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + return createPostResponse(userId, post); + } + + public PostResponse findById(Long userId, Long postId, @Nullable String shareKey) { + Post post = postRepository.findByIdFetchPollChoices(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + if (!post.isRevealable(userId, shareKey)) { + throw new BadRequestException(ErrorCode.POST_NOT_REVEALABLE); + } + return createPostResponse(userId, post); + } + + private PostResponse createPostResponse(Long userId, Post post) { + User author = userRepository.findById(post.getUserId()) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + + long commentCount = commentRepository.countByPostId(post.getId()); + List voteList = voteRepository.findAllByPostId(post.getId()); + long voterCount = voteList.stream() + .map(Vote::getUserId) + .distinct() + .count(); + boolean isAuthor = post.getUserId().equals(userId); + List pollChoiceVoteResponseList = createPollChoiceResponse( + userId, + post.getPollChoices(), + voteList + ); + + return PostResponse.of(post, author, pollChoiceVoteResponseList, isAuthor, commentCount, voterCount); + } + + private List createPollChoiceResponse(Long userId, List pollChoices, List voteList) { + return pollChoices.stream() + .map(pollChoice -> new PollChoiceVoteResponse( + pollChoice.getId(), + pollChoice.getTitle(), + pollChoice.getImageUrl(), + getVoteId(voteList, pollChoice.getId(), userId) + )) + .toList(); + } + + private Long getVoteId(List voteList, Long pollChoiceId, Long userId) { + return voteList.stream() + .filter(vote -> vote.getPollChoiceId().equals(pollChoiceId) && vote.getUserId().equals(userId)) + .map(Vote::getId) + .findFirst() + .orElse(null); + } + + public CursorBasePaginatedResponse findUserPosts( + Long userId, + Long myPageUserId, + Long cursor, + int size + ) { + return myPagePostManager.getUserPosts(userId, myPageUserId, cursor, Pageable.ofSize(size)); + } + + public CursorBasePaginatedResponse findVotedPosts( + Long userId, + Long myPageUserId, + Long cursor, + int size + ) { + return myPagePostManager.getVotedPosts(userId, myPageUserId, cursor, Pageable.ofSize(size)); + } + + public CursorBasePaginatedResponse findFeed(Long userId, Long cursor, int size) { + Slice postSlice = postRepository.findFeed(cursor, PageRequest.ofSize(size)); + return CursorBasePaginatedResponse.of(postSlice.map(post -> createFeedResponse(userId, post))); + } + + private FeedResponse createFeedResponse(Long userId, FeedDto feedDto) { + AuthorDto author = new AuthorDto(feedDto.postUserId(), feedDto.nickname(), feedDto.profileUrl()); + boolean isAuthor = feedDto.postUserId().equals(userId); + return FeedResponse.of(feedDto, author, isAuthor); + } + + public UpdatePostResponse findUpdatePost(Long userId, Long postId) { + Post post = postRepository.findByIdAndUserId(postId, userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + + return UpdatePostResponse.of(post); + } +} diff --git a/src/main/java/com/chooz/post/application/PostService.java b/src/main/java/com/chooz/post/application/PostService.java new file mode 100644 index 00000000..af33302e --- /dev/null +++ b/src/main/java/com/chooz/post/application/PostService.java @@ -0,0 +1,76 @@ +package com.chooz.post.application; + +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.post.presentation.dto.UpdatePostResponse; +import com.chooz.post.presentation.dto.CreatePostRequest; +import com.chooz.post.presentation.dto.CreatePostResponse; +import com.chooz.post.presentation.dto.FeedResponse; +import com.chooz.post.presentation.dto.MyPagePostResponse; +import com.chooz.post.presentation.dto.PostResponse; +import com.chooz.post.presentation.dto.UpdatePostRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PostService { + + private final PostCommandService postCommandService; + private final PostQueryService postQueryService; + + @Transactional + public CreatePostResponse create(Long userId, CreatePostRequest request) { + return postCommandService.create(userId, request); + } + + @Transactional + public void delete(Long userId, Long postId) { + postCommandService.delete(userId, postId); + } + + @Transactional + public void close(Long userId, Long postId) { + postCommandService.close(userId, postId); + } + + @Transactional + public void update(Long userId, Long postId, UpdatePostRequest request) { + postCommandService.update(userId, postId, request); + } + + public PostResponse findById(Long userId, Long postId, String shareKey) { + return postQueryService.findById(userId, postId, shareKey); + } + + public CursorBasePaginatedResponse findUserPosts( + Long userId, + Long myPageUserId, + Long cursor, + int size + ) { + return postQueryService.findUserPosts(userId, myPageUserId, cursor, size); + } + + public CursorBasePaginatedResponse findVotedPosts( + Long userId, + Long myPageUserId, + Long cursor, + int size + ) { + return postQueryService.findVotedPosts(userId, myPageUserId, cursor, size); + } + + public PostResponse findByShareUrl(Long userId, String shareUrl) { + return postQueryService.findByShareUrl(userId, shareUrl); + } + + public CursorBasePaginatedResponse findFeed(Long userId, Long cursor, int size) { + return postQueryService.findFeed(userId, cursor, size); + } + + public UpdatePostResponse findUpdatePost(Long userId, Long postId) { + return postQueryService.findUpdatePost(userId, postId); + } +} \ No newline at end of file diff --git a/src/main/java/com/chooz/post/application/PostValidator.java b/src/main/java/com/chooz/post/application/PostValidator.java new file mode 100644 index 00000000..5aab268f --- /dev/null +++ b/src/main/java/com/chooz/post/application/PostValidator.java @@ -0,0 +1,63 @@ +package com.chooz.post.application; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.CloseOption; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.Post; +import com.chooz.post.presentation.dto.UpdatePostRequest; +import com.chooz.vote.domain.VoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Component +@RequiredArgsConstructor +public class PostValidator { + + private final VoteRepository voteRepository; + + public void validateUpdate(Post post, Long userId, UpdatePostRequest request) { + CloseOption closeOption = post.getCloseOption(); + CloseType closeType = closeOption.getCloseType(); + + if (!post.isAuthor(userId)) { + throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); + } + if (post.isClosed()) { + throw new BadRequestException(ErrorCode.POST_ALREADY_CLOSED); + } + + LocalDateTime newClosedAt = request.closeOption().closedAt(); + Integer newMaxVoterCount = request.closeOption().maxVoterCount(); + if (closeType == CloseType.DATE) { + validateUpdateClosedAt(post, newClosedAt, newMaxVoterCount); + } else if (closeType == CloseType.VOTER) { + validateUpdateMaxVoter(post, newClosedAt, newMaxVoterCount); + } + } + + private void validateUpdateMaxVoter(Post post, LocalDateTime newClosedAt, Integer newMaxVoterCount) { + if (Objects.nonNull(newClosedAt) || Objects.isNull(newMaxVoterCount)) { + throw new BadRequestException(ErrorCode.INVALID_VOTER_CLOSE_OPTION); + } + long voterCount = voteRepository.countVoterByPostId(post.getId()); + if (newMaxVoterCount < 1 || newMaxVoterCount > 999) { + throw new BadRequestException(ErrorCode.INVALID_VOTER_CLOSE_OPTION); + } + if (newMaxVoterCount < voterCount) { + throw new BadRequestException(ErrorCode.INVALID_VOTER_CLOSE_OPTION); + } + } + + private static void validateUpdateClosedAt(Post post, LocalDateTime newClosedAt, Integer newMaxVoterCount) { + if (Objects.isNull(newClosedAt) || Objects.nonNull(newMaxVoterCount)) { + throw new BadRequestException(ErrorCode.INVALID_DATE_CLOSE_OPTION); + } + if (newClosedAt.isBefore(LocalDateTime.now()) || newClosedAt.isBefore(post.getCreatedAt().plusHours(1))) { + throw new BadRequestException(ErrorCode.INVALID_DATE_CLOSE_OPTION); + } + } +} diff --git a/src/main/java/com/chooz/post/application/PostVotedEventListener.java b/src/main/java/com/chooz/post/application/PostVotedEventListener.java new file mode 100644 index 00000000..b755b453 --- /dev/null +++ b/src/main/java/com/chooz/post/application/PostVotedEventListener.java @@ -0,0 +1,46 @@ +package com.chooz.post.application; + +import com.chooz.common.event.EventPublisher; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.application.dto.PostClosedNotificationEvent; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.vote.application.VotedEvent; +import com.chooz.vote.domain.VoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class PostVotedEventListener { + + private final PostRepository postRepository; + private final VoteRepository voteRepository; + private final EventPublisher eventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(VotedEvent event) { + Post post = postRepository.findById(event.postId()) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + handleClosePost(post); + } + + private void handleClosePost(Post post) { + long voterCount = voteRepository.countVoterByPostId(post.getId()); + if (post.isClosableByVoterCount(voterCount)) { + post.close(); + eventPublisher.publish(new PostClosedNotificationEvent( + post.getId(), + post.getUserId(), + post.getCloseOption().getCloseType(), + LocalDateTime.now() + ) + ); + } + } +} diff --git a/src/main/java/com/chooz/post/application/ShareUrlKeyGenerator.java b/src/main/java/com/chooz/post/application/ShareUrlKeyGenerator.java new file mode 100644 index 00000000..ee279d62 --- /dev/null +++ b/src/main/java/com/chooz/post/application/ShareUrlKeyGenerator.java @@ -0,0 +1,26 @@ +package com.chooz.post.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@RequiredArgsConstructor +public class ShareUrlKeyGenerator { + + private final AtomicInteger counter = new AtomicInteger(0); + private final Clock clock; + + public String generateKey() { + int currentCount = counter.getAndUpdate(i -> i > 100 ? i + 1 : 0); + long now = LocalDateTime.now(clock) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + return String.format("%d%d", now, currentCount); + } +} diff --git a/src/main/java/com/chooz/post/application/ShareUrlService.java b/src/main/java/com/chooz/post/application/ShareUrlService.java new file mode 100644 index 00000000..368b7483 --- /dev/null +++ b/src/main/java/com/chooz/post/application/ShareUrlService.java @@ -0,0 +1,37 @@ +package com.chooz.post.application; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import io.seruco.encoding.base62.Base62; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; + +@Service +@RequiredArgsConstructor +public class ShareUrlService { + + private final ShareUrlKeyGenerator shareUrlKeyGenerator; + + private final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + private final int BASE = ALPHABET.length(); + + public String generateShareUrl() { + String key = shareUrlKeyGenerator.generateKey(); + return encodeBase62(key); + } + + private String encodeBase62(String key) { + long number = Long.parseLong(key); + + StringBuilder sb = new StringBuilder(); + while (number > 0) { + sb.append(ALPHABET.charAt((int) (number % BASE))); + number /= BASE; + } + return sb.reverse().toString(); + } +} diff --git a/src/main/java/com/chooz/post/application/dto/FeedDto.java b/src/main/java/com/chooz/post/application/dto/FeedDto.java new file mode 100644 index 00000000..d822541a --- /dev/null +++ b/src/main/java/com/chooz/post/application/dto/FeedDto.java @@ -0,0 +1,21 @@ +package com.chooz.post.application.dto; + +import com.chooz.post.domain.Status; +import com.querydsl.core.annotations.QueryProjection; + +import java.time.LocalDateTime; + +@QueryProjection +public record FeedDto( + Long postId, + Status status, + String title, + String thumbnailUrl, + Long postUserId, + String nickname, + String profileUrl, + Long voterCount, + Long commentCount, + LocalDateTime createdAt +) { +} \ No newline at end of file diff --git a/src/main/java/com/chooz/post/application/dto/MostVotedPollChoice.java b/src/main/java/com/chooz/post/application/dto/MostVotedPollChoice.java new file mode 100644 index 00000000..0344f050 --- /dev/null +++ b/src/main/java/com/chooz/post/application/dto/MostVotedPollChoice.java @@ -0,0 +1,9 @@ +package com.chooz.post.application.dto; + +public record MostVotedPollChoice( + Long postId, + Long pollChoiceId, + String title, + Long voteCount +) { +} diff --git a/src/main/java/com/chooz/post/application/dto/PollChoiceVoteInfo.java b/src/main/java/com/chooz/post/application/dto/PollChoiceVoteInfo.java new file mode 100644 index 00000000..c103538d --- /dev/null +++ b/src/main/java/com/chooz/post/application/dto/PollChoiceVoteInfo.java @@ -0,0 +1,9 @@ +package com.chooz.post.application.dto; + +public record PollChoiceVoteInfo( + Long postId, + Long pollChoiceId, + Long voteCounts, + String title +) { +} diff --git a/src/main/java/com/chooz/post/application/dto/PostClosedNotificationEvent.java b/src/main/java/com/chooz/post/application/dto/PostClosedNotificationEvent.java new file mode 100644 index 00000000..035e8f5c --- /dev/null +++ b/src/main/java/com/chooz/post/application/dto/PostClosedNotificationEvent.java @@ -0,0 +1,13 @@ +package com.chooz.post.application.dto; + +import com.chooz.post.domain.CloseType; + +import java.time.LocalDateTime; + +public record PostClosedNotificationEvent( + Long postId, + Long userId, + CloseType closeType, + LocalDateTime eventAt +) {} + diff --git a/src/main/java/com/chooz/post/application/dto/PostWithVoteCount.java b/src/main/java/com/chooz/post/application/dto/PostWithVoteCount.java new file mode 100644 index 00000000..40c58777 --- /dev/null +++ b/src/main/java/com/chooz/post/application/dto/PostWithVoteCount.java @@ -0,0 +1,11 @@ +package com.chooz.post.application.dto; + +import com.chooz.post.domain.Post; +import com.querydsl.core.annotations.QueryProjection; + +@QueryProjection +public record PostWithVoteCount( + Post post, + long voteCount +) { +} diff --git a/src/main/java/com/chooz/post/domain/CloseOption.java b/src/main/java/com/chooz/post/domain/CloseOption.java new file mode 100644 index 00000000..2e156285 --- /dev/null +++ b/src/main/java/com/chooz/post/domain/CloseOption.java @@ -0,0 +1,66 @@ +package com.chooz.post.domain; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; + +import static com.chooz.common.util.Validator.validateNull; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CloseOption { + + @Enumerated(EnumType.STRING) + private CloseType closeType; + + private LocalDateTime closedAt; + + private Integer maxVoterCount; + + @Builder + public CloseOption(CloseType closeType, LocalDateTime closedAt, Integer maxVoterCount) { + validateNull(closeType); + this.closeType = closeType; + this.closedAt = closedAt; + this.maxVoterCount = maxVoterCount; + } + + public static CloseOption create(CloseType closeType, LocalDateTime closedAt, Integer maxVoterCount) { + switch (closeType) { + case SELF -> { + return new CloseOption(closeType, null, null); + } + case DATE -> { + validateDateCloseType(closedAt); + return new CloseOption(closeType, closedAt, null); + } + case VOTER -> { + validateVoterCloseType(maxVoterCount); + return new CloseOption(closeType, closedAt, maxVoterCount); + } + default -> throw new BadRequestException(ErrorCode.INVALID_CLOSE_OPTION); + } + } + + private static void validateVoterCloseType(Integer maxVoterCount) { + if (Objects.isNull(maxVoterCount) || (maxVoterCount < 1 || maxVoterCount > 999)) { + throw new BadRequestException(ErrorCode.INVALID_VOTER_CLOSE_OPTION); + } + } + + private static void validateDateCloseType(LocalDateTime closedAt) { + if (Objects.isNull(closedAt) || closedAt.isBefore(LocalDateTime.now().plusMinutes(59))) { + throw new BadRequestException(ErrorCode.INVALID_DATE_CLOSE_OPTION); + } + } +} diff --git a/src/main/java/com/chooz/post/domain/CloseType.java b/src/main/java/com/chooz/post/domain/CloseType.java new file mode 100644 index 00000000..dca7b19c --- /dev/null +++ b/src/main/java/com/chooz/post/domain/CloseType.java @@ -0,0 +1,5 @@ +package com.chooz.post.domain; + +public enum CloseType { + SELF, DATE, VOTER +} \ No newline at end of file diff --git a/src/main/java/com/chooz/post/domain/CommentActive.java b/src/main/java/com/chooz/post/domain/CommentActive.java new file mode 100644 index 00000000..16f5813a --- /dev/null +++ b/src/main/java/com/chooz/post/domain/CommentActive.java @@ -0,0 +1,5 @@ +package com.chooz.post.domain; + +public enum CommentActive { + OPEN, CLOSED +} \ No newline at end of file diff --git a/src/main/java/com/chooz/post/domain/PollChoice.java b/src/main/java/com/chooz/post/domain/PollChoice.java new file mode 100644 index 00000000..b4401527 --- /dev/null +++ b/src/main/java/com/chooz/post/domain/PollChoice.java @@ -0,0 +1,60 @@ +package com.chooz.post.domain; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static com.chooz.common.util.Validator.validateNull; + +@Getter +@Entity +@Table(name = "poll_choices") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollChoice { + + private static final int MAX_TITLE_LENGTH = 10; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + private Post post; + + private String title; + + private String imageUrl; + + public PollChoice(Long id, Post post, String title, String imageUrl) { + validateNull(title, imageUrl); + validateTitleLength(title); + this.id = id; + this.post = post; + this.title = title; + this.imageUrl = imageUrl; + } + + public static PollChoice create(String title, String imageUrl) { + return new PollChoice(null, null, title, imageUrl); + } + + public void setPost(Post post) { + validateNull(post); + this.post = post; + } + + private void validateTitleLength(String title) { + if (title.length() > MAX_TITLE_LENGTH) { + throw new BadRequestException(ErrorCode.POLL_CHOICE_TITLE_LENGTH_EXCEEDED); + } + } +} diff --git a/src/main/java/com/chooz/post/domain/PollChoiceRepository.java b/src/main/java/com/chooz/post/domain/PollChoiceRepository.java new file mode 100644 index 00000000..86e4c22d --- /dev/null +++ b/src/main/java/com/chooz/post/domain/PollChoiceRepository.java @@ -0,0 +1,27 @@ +package com.chooz.post.domain; + +import com.chooz.post.application.dto.PollChoiceVoteInfo; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PollChoiceRepository extends JpaRepository { + + @Query(""" + select new com.chooz.post.application.dto.PollChoiceVoteInfo( + pc.post.id, + pc.id, + count(v.id), + pc.title + ) + from PollChoice pc + left join Vote v on pc.id = v.pollChoiceId + where pc.post.id in :postIds + group by pc.post.id, pc.id + """) + List findPollChoiceWithVoteInfo(@Param("postIds") List postIds); +} diff --git a/src/main/java/com/chooz/post/domain/PollOption.java b/src/main/java/com/chooz/post/domain/PollOption.java new file mode 100644 index 00000000..73627d33 --- /dev/null +++ b/src/main/java/com/chooz/post/domain/PollOption.java @@ -0,0 +1,38 @@ +package com.chooz.post.domain; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static com.chooz.common.util.Validator.validateNull; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollOption { + + @Enumerated(EnumType.STRING) + private PollType pollType; + + @Enumerated(EnumType.STRING) + private Scope scope; + + @Enumerated(EnumType.STRING) + private CommentActive commentActive; + + @Builder + private PollOption(PollType pollType, Scope scope, CommentActive commentActive) { + this.pollType = pollType; + this.scope = scope; + this.commentActive = commentActive; + } + + public static PollOption create(PollType pollType, Scope scope, CommentActive commentActive) { + validateNull(pollType, scope, commentActive); + return new PollOption(pollType, scope, commentActive); + } +} diff --git a/src/main/java/com/chooz/post/domain/PollType.java b/src/main/java/com/chooz/post/domain/PollType.java new file mode 100644 index 00000000..5c00a875 --- /dev/null +++ b/src/main/java/com/chooz/post/domain/PollType.java @@ -0,0 +1,5 @@ +package com.chooz.post.domain; + +public enum PollType { + SINGLE, MULTIPLE +} \ No newline at end of file diff --git a/src/main/java/com/chooz/post/domain/Post.java b/src/main/java/com/chooz/post/domain/Post.java new file mode 100644 index 00000000..4296bd79 --- /dev/null +++ b/src/main/java/com/chooz/post/domain/Post.java @@ -0,0 +1,234 @@ +package com.chooz.post.domain; + +import com.chooz.common.domain.BaseEntity; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.InternalServerException; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.springframework.util.StringUtils; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static com.chooz.common.util.Validator.validateNull; + +@Getter +@Entity +@ToString(exclude = "pollChoices") +@Table(name = "posts") +@EqualsAndHashCode(of = "id", callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private String description; + + private String imageUrl; + + private Long userId; + + @Enumerated(EnumType.STRING) + private Status status; + + @OneToMany(mappedBy = "post", orphanRemoval = true, cascade = CascadeType.ALL) + private List pollChoices = new ArrayList<>(); + + private String shareUrl; + + @Embedded + private PollOption pollOption; + + @Embedded + private CloseOption closeOption; + + @Builder + private Post( + Long id, + Long userId, + String title, + String description, + String imageUrl, + Status status, + List pollChoices, + String shareUrl, + PollOption pollOption, + CloseOption closeOption + ) { + validateNull(userId, title, description, pollChoices, imageUrl); + validateTitle(title); + validateDescription(description); + validatePollChoices(pollChoices); + this.id = id; + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + this.userId = userId; + this.status = status; + this.pollChoices = pollChoices; + pollChoices.forEach(pollChoice -> pollChoice.setPost(this)); + this.shareUrl = shareUrl; + this.pollOption = pollOption; + this.closeOption = closeOption; + } + + public static Post create( + Long userId, + String title, + String description, + String imageUrl, + List pollChoices, + String shareUrl, + PollOption pollOption, + CloseOption closeOption + ) { + return new Post( + null, + userId, + title, + description, + imageUrl, + Status.PROGRESS, + pollChoices, + shareUrl, + pollOption, + closeOption + ); + } + + private static void validatePollChoices(List images) { + if (images.size() < 2 || images.size() > 10) { + throw new BadRequestException(ErrorCode.INVALID_POLL_CHOICE_COUNT); + } + } + + private static void validateDescription(String description) { + if (description.length() > 100) { + throw new BadRequestException(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED); + } + } + + private static void validateTitle(String title) { + if (StringUtils.hasText(title) && title.length() > 50) { + throw new BadRequestException(ErrorCode.TITLE_LENGTH_EXCEEDED); + } + } + + public void closeByAuthor(Long userId) { + if (!isAuthor(userId)) { + throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); + } + if (closeOption.getCloseType() != CloseType.SELF) { + throw new BadRequestException(ErrorCode.ONLY_SELF_CAN_CLOSE); + } + close(); + } + + public void close() { + if (status == Status.CLOSED) { + throw new BadRequestException(ErrorCode.POST_ALREADY_CLOSED); + } + this.status = Status.CLOSED; + } + + public boolean isAuthor(Long userId) { + return this.userId.equals(userId); + } + + public void validateProgress() { + if (!this.status.equals(Status.PROGRESS)) { + throw new BadRequestException(ErrorCode.POST_ALREADY_CLOSED); + } + } + + public void setShareUrl(String shareUrl) { + if (Objects.nonNull(this.shareUrl)) { + throw new InternalServerException(ErrorCode.SHARE_URL_ALREADY_EXISTS); + } + this.shareUrl = shareUrl; + } + + public void validateCloseDate(Clock clock) { + if (closeOption.getClosedAt().isBefore(LocalDateTime.now())) { + throw new BadRequestException(ErrorCode.CLOSE_DATE_OVER); + } + } + + public void validateMaxVoterCount(long voterCount) { + if (closeOption.getMaxVoterCount() <= voterCount) { + throw new BadRequestException(ErrorCode.EXCEED_MAX_VOTER_COUNT); + } + } + + public boolean isSingleVote() { + return PollType.SINGLE.equals(pollOption.getPollType()); + } + + public boolean isClosableByVoterCount(long voterCount) { + if (!isCloseTypeVoter()) { + return false; + } + return closeOption.getMaxVoterCount() == voterCount; + } + + private boolean isCloseTypeVoter() { + return CloseType.VOTER.equals(closeOption.getCloseType()); + } + + public boolean isClosed() { + return this.status.equals(Status.CLOSED); + } + + public void update( + Long userId, + String title, + String description, + PollOption pollOption, + CloseOption closeOption + ) { + validateTitle(title); + validateDescription(description); + this.title = title; + this.description = description; + this.pollOption = pollOption; + this.closeOption = closeOption; + } + + public void delete(Long userId) { + if (!isAuthor(userId)) { + throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); + } + this.delete(); + } + + public boolean isRevealable(Long userId) { + return this.pollOption.getScope().equals(Scope.PUBLIC) || + this.userId.equals(userId); + } + + public boolean isRevealable(Long userId, String shareUrl) { + return isRevealable(userId) || this.shareUrl.equals(shareUrl); + } +} diff --git a/src/main/java/com/chooz/post/domain/PostRepository.java b/src/main/java/com/chooz/post/domain/PostRepository.java new file mode 100644 index 00000000..7b370280 --- /dev/null +++ b/src/main/java/com/chooz/post/domain/PostRepository.java @@ -0,0 +1,38 @@ +package com.chooz.post.domain; + +import com.chooz.post.application.dto.FeedDto; +import com.chooz.post.application.dto.PostWithVoteCount; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PostRepository { + + Optional findById(Long postId); + + Post save(Post post); + + Slice findAllByUserId(Long userId, Long postId, Pageable pageable); + + Optional findByIdFetchPollChoices(Long postId); + + Optional findByIdFetchPollChoicesWithLock(Long postId); + + Slice findFeed(Long postId, Pageable pageable); + + Optional findByShareUrlFetchPollChoices(String shareUrl); + + List findPostsNeedToClose(); + + Optional findCommentActiveByPostId(Long postId); + + Slice findPostsWithVoteCountByUserId(Long userId, Long authorId, Long postId, Pageable pageable); + + Slice findVotedPostsWithVoteCount(Long userId, Long authorId, Long postId, Pageable pageable); + + Optional findByIdAndUserId(Long postId, Long userId); +} diff --git a/src/main/java/com/swyp8team2/post/domain/Scope.java b/src/main/java/com/chooz/post/domain/Scope.java similarity index 54% rename from src/main/java/com/swyp8team2/post/domain/Scope.java rename to src/main/java/com/chooz/post/domain/Scope.java index 31cda011..2a2c658f 100644 --- a/src/main/java/com/swyp8team2/post/domain/Scope.java +++ b/src/main/java/com/chooz/post/domain/Scope.java @@ -1,4 +1,4 @@ -package com.swyp8team2.post.domain; +package com.chooz.post.domain; public enum Scope { PUBLIC, PRIVATE diff --git a/src/main/java/com/swyp8team2/post/domain/Status.java b/src/main/java/com/chooz/post/domain/Status.java similarity index 55% rename from src/main/java/com/swyp8team2/post/domain/Status.java rename to src/main/java/com/chooz/post/domain/Status.java index bda22f18..4d9ab9f3 100644 --- a/src/main/java/com/swyp8team2/post/domain/Status.java +++ b/src/main/java/com/chooz/post/domain/Status.java @@ -1,4 +1,4 @@ -package com.swyp8team2.post.domain; +package com.chooz.post.domain; public enum Status { PROGRESS, CLOSED diff --git a/src/main/java/com/chooz/post/persistence/PostJpaRepository.java b/src/main/java/com/chooz/post/persistence/PostJpaRepository.java new file mode 100644 index 00000000..0c9f6a48 --- /dev/null +++ b/src/main/java/com/chooz/post/persistence/PostJpaRepository.java @@ -0,0 +1,75 @@ +package com.chooz.post.persistence; + +import com.chooz.post.application.dto.PostWithVoteCount; +import com.chooz.post.domain.CommentActive; +import com.chooz.post.domain.Post; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PostJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedFalse(Long postId); + + @Query(""" + SELECT p + FROM Post p + JOIN FETCH p.pollChoices + WHERE p.id = :postId + AND p.deleted = false + """ + ) + Optional findByIdFetchPollChoices(@Param("postId") Long postId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT p + FROM Post p + JOIN FETCH p.pollChoices + WHERE p.id = :postId + AND p.deleted = false + """ + ) + Optional findByIdFetchPollChoicesWithLock(@Param("postId") Long postId); + + @Query(""" + SELECT p + FROM Post p + JOIN FETCH p.pollChoices + WHERE p.shareUrl = :shareUrl + AND p.deleted = false + """ + ) + Optional findByShareUrlFetchPollChoices(@Param("shareUrl") String shareUrl); + + @Query(""" + SELECT p + FROM Post p + WHERE p.closeOption.closeType = 'DATE' + AND p.status = 'PROGRESS' + AND p.closeOption.closedAt <= CURRENT_TIMESTAMP + AND p.deleted = false + """ + ) + List findPostsNeedToClose(); + + @Query(""" + SELECT p.pollOption.commentActive + FROM Post p + WHERE p.id = :postId + AND p.deleted = false + """ + ) + Optional findCommentActiveByPostId(@Param("postId") Long postId); + + Optional findByIdAndUserIdAndDeletedFalse(Long postId, Long userId); +} diff --git a/src/main/java/com/chooz/post/persistence/PostQueryDslRepository.java b/src/main/java/com/chooz/post/persistence/PostQueryDslRepository.java new file mode 100644 index 00000000..2295b963 --- /dev/null +++ b/src/main/java/com/chooz/post/persistence/PostQueryDslRepository.java @@ -0,0 +1,184 @@ +package com.chooz.post.persistence; + +import com.chooz.post.application.dto.FeedDto; +import com.chooz.post.application.dto.PostWithVoteCount; +import com.chooz.post.application.dto.QFeedDto; +import com.chooz.post.application.dto.QPostWithVoteCount; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.Scope; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.chooz.comment.domain.QComment.comment; +import static com.chooz.post.domain.QPost.post; +import static com.chooz.user.domain.QUser.user; +import static com.chooz.vote.domain.QVote.vote; + +@Repository +@RequiredArgsConstructor +public class PostQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + Slice findByUserId(Long userId, Long postId, Pageable pageable) { + List results = queryFactory.selectFrom(post) + .where( + post.userId.eq(userId), + cursor(postId, post.id), + post.deleted.isFalse() + ) + .orderBy(post.id.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + boolean hasNext = isHasNext(pageable, results); + + if (hasNext) { + results.removeLast(); + } + return new SliceImpl<>(results, pageable, hasNext); + } + + private Predicate cursor(Long cursor, NumberPath id) { + return cursor != null ? id.lt(cursor) : null; + } + + private boolean isHasNext(Pageable pageable, List results) { + return results.size() > pageable.getPageSize(); + } + + /** + * ν”Όλ“œ κ΄€λ ¨ 데이터 쑰회 + * + * @param postId + * @param pageable + * @return + */ + public Slice findFeed(Long postId, Pageable pageable) { + List results = queryFactory + .select(new QFeedDto( + post.id, + post.status, + post.title, + post.imageUrl, + post.userId, + user.nickname, + user.profileUrl, + JPAExpressions + .select(vote.userId.countDistinct()) + .from(vote) + .where( + vote.postId.eq(post.id), + vote.deleted.isFalse() + ), + JPAExpressions + .select(comment.count()) + .from(comment) + .where( + comment.postId.eq(post.id), + comment.deleted.isFalse() + ), + post.createdAt + )) + .from(post) + .innerJoin(user).on(post.userId.eq(user.id)) + .where( + post.pollOption.scope.eq(Scope.PUBLIC), + cursor(postId, post.id), + post.deleted.isFalse() + ) + .orderBy(post.createdAt.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + boolean hasNext = isHasNext(pageable, results); + + if (hasNext) { + results.removeLast(); + } + return new SliceImpl<>(results, pageable, hasNext); + } + + /** + * μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 리슀트 쑰회 + * + * @param userId + * @param postId + * @param pageable + * @return + */ + public Slice findPostsWithVoteCountByUserId(Long userId, Long authorId, Long postId, Pageable pageable) { + BooleanBuilder builder = new BooleanBuilder( + post.userId.eq(authorId) + .and(cursor(postId, post.id)) + .and(post.deleted.isFalse()) + ); + return getPostWithVoteCounts(userId, authorId, pageable, builder); + } + + /** + * μœ μ €κ°€ νˆ¬ν‘œν•œ κ²Œμ‹œκΈ€ 리슀트 쑰회 + * + * @param userId + * @param authorId + * @param postId + * @param pageable + * @return + */ + public Slice findVotedPostsWithVoteCount(Long userId, Long authorId, Long postId, Pageable pageable) { + BooleanBuilder builder = new BooleanBuilder( + post.id.in( + JPAExpressions + .select(vote.postId) + .from(vote) + .where( + vote.userId.eq(authorId), + vote.deleted.isFalse() + ) + ) + .and(cursor(postId, post.id)) + .and(post.deleted.isFalse()) + ); + return getPostWithVoteCounts(userId, authorId, pageable, builder); + } + + private Slice getPostWithVoteCounts(Long userId, Long authorId, Pageable pageable, BooleanBuilder builder) { + if (!userId.equals(authorId)) { + builder.and(post.pollOption.scope.eq(Scope.PUBLIC)); + } + + List results = queryFactory + .select(new QPostWithVoteCount( + post, + JPAExpressions + .select(vote.userId.count()) + .from(vote) + .where( + vote.postId.eq(post.id), + vote.deleted.isFalse() + ) + )) + .from(post) + .where(builder) + .orderBy(post.id.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + boolean hasNext = isHasNext(pageable, results); + + if (hasNext) { + results.removeLast(); + } + return new SliceImpl<>(results, pageable, hasNext); + } +} diff --git a/src/main/java/com/chooz/post/persistence/PostRepositoryImpl.java b/src/main/java/com/chooz/post/persistence/PostRepositoryImpl.java new file mode 100644 index 00000000..6432f971 --- /dev/null +++ b/src/main/java/com/chooz/post/persistence/PostRepositoryImpl.java @@ -0,0 +1,82 @@ +package com.chooz.post.persistence; + +import com.chooz.post.application.dto.PostWithVoteCount; +import com.chooz.post.domain.CommentActive; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.post.application.dto.FeedDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class PostRepositoryImpl implements PostRepository { + + private final PostJpaRepository postJpaRepository; + private final PostQueryDslRepository postQueryDslRepository; + + @Override + public Optional findById(Long postId) { + return postJpaRepository.findByIdAndDeletedFalse(postId); + } + + @Override + public Post save(Post post) { + return postJpaRepository.save(post); + } + + @Override + public Slice findAllByUserId(Long userId, Long postId, Pageable pageable) { + return postQueryDslRepository.findByUserId(userId, postId, pageable); + } + + @Override + public Optional findByIdFetchPollChoices(Long postId) { + return postJpaRepository.findByIdFetchPollChoices(postId); + } + + @Override + public Optional findByIdFetchPollChoicesWithLock(Long postId) { + return postJpaRepository.findByIdFetchPollChoicesWithLock(postId); + } + + @Override + public Slice findFeed(Long postId, Pageable pageable) { + return postQueryDslRepository.findFeed(postId, pageable); + } + + @Override + public Optional findByShareUrlFetchPollChoices(String shareUrl) { + return postJpaRepository.findByShareUrlFetchPollChoices(shareUrl); + } + + @Override + public List findPostsNeedToClose() { + return postJpaRepository.findPostsNeedToClose(); + } + + @Override + public Optional findCommentActiveByPostId(Long postId) { + return postJpaRepository.findCommentActiveByPostId(postId); + } + + @Override + public Slice findPostsWithVoteCountByUserId(Long userId, Long authorId, Long postId, Pageable pageable) { + return postQueryDslRepository.findPostsWithVoteCountByUserId(userId, authorId, postId, pageable); + } + + @Override + public Slice findVotedPostsWithVoteCount(Long userId, Long authorId, Long postId, Pageable pageable) { + return postQueryDslRepository.findVotedPostsWithVoteCount(userId, authorId, postId, pageable); + } + + @Override + public Optional findByIdAndUserId(Long postId, Long userId) { + return postJpaRepository.findByIdAndUserIdAndDeletedFalse(postId, userId); + } +} diff --git a/src/main/java/com/swyp8team2/post/presentation/PostController.java b/src/main/java/com/chooz/post/presentation/PostController.java similarity index 66% rename from src/main/java/com/swyp8team2/post/presentation/PostController.java rename to src/main/java/com/chooz/post/presentation/PostController.java index 489431f5..1c7b2e7f 100644 --- a/src/main/java/com/swyp8team2/post/presentation/PostController.java +++ b/src/main/java/com/chooz/post/presentation/PostController.java @@ -1,14 +1,15 @@ -package com.swyp8team2.post.presentation; +package com.chooz.post.presentation; -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.common.dto.CursorBasePaginatedResponse; -import com.swyp8team2.post.application.PostService; -import com.swyp8team2.post.presentation.dto.CreatePostRequest; -import com.swyp8team2.post.presentation.dto.CreatePostResponse; -import com.swyp8team2.post.presentation.dto.PostResponse; -import com.swyp8team2.post.presentation.dto.UpdatePostRequest; -import com.swyp8team2.post.presentation.dto.SimplePostResponse; -import com.swyp8team2.post.presentation.dto.FeedResponse; +import com.chooz.auth.domain.UserInfo; +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.post.application.PostService; +import com.chooz.post.presentation.dto.CreatePostRequest; +import com.chooz.post.presentation.dto.CreatePostResponse; +import com.chooz.post.presentation.dto.PostResponse; +import com.chooz.post.presentation.dto.UpdatePostRequest; +import com.chooz.post.presentation.dto.MyPagePostResponse; +import com.chooz.post.presentation.dto.FeedResponse; +import com.chooz.post.presentation.dto.UpdatePostResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -40,6 +42,18 @@ public ResponseEntity createPost( return ResponseEntity.ok(postService.create(userInfo.userId(), request)); } + @GetMapping("/{postId}") + public ResponseEntity findPostById( + @PathVariable("postId") Long postId, + @RequestParam(value = "shareKey", required = false ) String shareKey, + @AuthenticationPrincipal UserInfo userInfo + ) { + Long userId = Optional.ofNullable(userInfo) + .map(UserInfo::userId) + .orElse(null); + return ResponseEntity.ok(postService.findById(userId, postId, shareKey)); + } + @GetMapping("/shareUrl/{shareUrl}") public ResponseEntity findPostByShareUrl( @PathVariable("shareUrl") String shareUrl, @@ -51,22 +65,23 @@ public ResponseEntity findPostByShareUrl( return ResponseEntity.ok(postService.findByShareUrl(userId, shareUrl)); } - @PostMapping("/{postId}/scope") - public ResponseEntity toggleScopePost( + @PutMapping("/{postId}") + public ResponseEntity updatePost( @PathVariable("postId") Long postId, + @Valid @RequestBody UpdatePostRequest request, @AuthenticationPrincipal UserInfo userInfo ) { - postService.toggleScope(userInfo.userId(), postId); + postService.update(userInfo.userId(), postId, request); return ResponseEntity.ok().build(); } - @PostMapping("/{postId}/update") - public ResponseEntity updatePost( + @GetMapping("/{postId}/update") + public ResponseEntity updatePost( @PathVariable("postId") Long postId, - @Valid @RequestBody UpdatePostRequest request, @AuthenticationPrincipal UserInfo userInfo ) { - return ResponseEntity.ok().build(); + UpdatePostResponse response = postService.findUpdatePost(userInfo.userId(), postId); + return ResponseEntity.ok(response); } @PostMapping("/{postId}/close") @@ -88,21 +103,23 @@ public ResponseEntity deletePost( } @GetMapping("/users/{userId}") - public ResponseEntity> findMyPosts( - @PathVariable("userId") Long userId, + public ResponseEntity> findMyPosts( + @PathVariable("userId") Long myPageUserId, + @AuthenticationPrincipal UserInfo userInfo, @RequestParam(name = "cursor", required = false) @Min(0) Long cursor, @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size ) { - return ResponseEntity.ok(postService.findUserPosts(userId, cursor, size)); + return ResponseEntity.ok(postService.findUserPosts(userInfo.userId(), myPageUserId, cursor, size)); } @GetMapping("/users/{userId}/voted") - public ResponseEntity> findVotedPosts( - @PathVariable("userId") Long userId, + public ResponseEntity> findVotedPosts( + @PathVariable("userId") Long myPageUserId, + @AuthenticationPrincipal UserInfo userInfo, @RequestParam(name = "cursor", required = false) @Min(0) Long cursor, @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size ) { - return ResponseEntity.ok(postService.findVotedPosts(userId, cursor, size)); + return ResponseEntity.ok(postService.findVotedPosts(userInfo.userId(), myPageUserId, cursor, size)); } @GetMapping("/feed") diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/AuthorDto.java b/src/main/java/com/chooz/post/presentation/dto/AuthorDto.java similarity index 77% rename from src/main/java/com/swyp8team2/post/presentation/dto/AuthorDto.java rename to src/main/java/com/chooz/post/presentation/dto/AuthorDto.java index f2cc0907..8106c51d 100644 --- a/src/main/java/com/swyp8team2/post/presentation/dto/AuthorDto.java +++ b/src/main/java/com/chooz/post/presentation/dto/AuthorDto.java @@ -1,6 +1,6 @@ -package com.swyp8team2.post.presentation.dto; +package com.chooz.post.presentation.dto; -import com.swyp8team2.user.domain.User; +import com.chooz.user.domain.User; public record AuthorDto( Long id, diff --git a/src/main/java/com/chooz/post/presentation/dto/CloseOptionDto.java b/src/main/java/com/chooz/post/presentation/dto/CloseOptionDto.java new file mode 100644 index 00000000..2e141a65 --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/CloseOptionDto.java @@ -0,0 +1,16 @@ +package com.chooz.post.presentation.dto; + +import com.chooz.post.domain.CloseType; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +public record CloseOptionDto( + @NotNull + CloseType closeType, + + LocalDateTime closedAt, + + Integer maxVoterCount +) { +} diff --git a/src/main/java/com/chooz/post/presentation/dto/CreatePostRequest.java b/src/main/java/com/chooz/post/presentation/dto/CreatePostRequest.java new file mode 100644 index 00000000..a737425b --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/CreatePostRequest.java @@ -0,0 +1,29 @@ +package com.chooz.post.presentation.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record CreatePostRequest( + + @NotBlank + String title, + + @NotNull + String description, + + @Valid + @NotNull + List pollChoices, + + @Valid + @NotNull + PollOptionDto pollOption, + + @Valid + @NotNull + CloseOptionDto closeOption +) { +} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostResponse.java b/src/main/java/com/chooz/post/presentation/dto/CreatePostResponse.java similarity index 59% rename from src/main/java/com/swyp8team2/post/presentation/dto/CreatePostResponse.java rename to src/main/java/com/chooz/post/presentation/dto/CreatePostResponse.java index 172d42b3..bee5bcac 100644 --- a/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostResponse.java +++ b/src/main/java/com/chooz/post/presentation/dto/CreatePostResponse.java @@ -1,4 +1,4 @@ -package com.swyp8team2.post.presentation.dto; +package com.chooz.post.presentation.dto; public record CreatePostResponse(Long postId, String shareUrl) { } diff --git a/src/main/java/com/chooz/post/presentation/dto/FeedResponse.java b/src/main/java/com/chooz/post/presentation/dto/FeedResponse.java new file mode 100644 index 00000000..fe6f5ba5 --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/FeedResponse.java @@ -0,0 +1,40 @@ +package com.chooz.post.presentation.dto; + +import com.chooz.common.dto.CursorDto; +import com.chooz.post.application.dto.FeedDto; +import com.chooz.post.domain.Status; + +import java.time.LocalDateTime; + +public record FeedResponse( + Long id, + AuthorDto author, + Status status, + String title, + String thumbnailUrl, + boolean isAuthor, + Long voterCount, + Long commentCount, + LocalDateTime createdAt + +) implements CursorDto { + + public static FeedResponse of(FeedDto feedDto, AuthorDto author, boolean isAuthor) { + return new FeedResponse( + feedDto.postId(), + author, + feedDto.status(), + feedDto.title(), + feedDto.thumbnailUrl(), + isAuthor, + feedDto.voterCount(), + feedDto.commentCount(), + feedDto.createdAt() + ); + } + + @Override + public long getId() { + return id; + } +} diff --git a/src/main/java/com/chooz/post/presentation/dto/MostVotedPollChoiceDto.java b/src/main/java/com/chooz/post/presentation/dto/MostVotedPollChoiceDto.java new file mode 100644 index 00000000..a9514b9d --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/MostVotedPollChoiceDto.java @@ -0,0 +1,19 @@ +package com.chooz.post.presentation.dto; + +import com.chooz.post.application.dto.PollChoiceVoteInfo; + +public record MostVotedPollChoiceDto( + long id, + String title, + long voteCount, + String voteRatio +) { + public static MostVotedPollChoiceDto of(PollChoiceVoteInfo pollChoiceVoteInfo, String voteRatio) { + return new MostVotedPollChoiceDto( + pollChoiceVoteInfo.postId(), + pollChoiceVoteInfo.title(), + pollChoiceVoteInfo.voteCounts(), + voteRatio + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/chooz/post/presentation/dto/MyPagePostResponse.java b/src/main/java/com/chooz/post/presentation/dto/MyPagePostResponse.java new file mode 100644 index 00000000..e807fdb0 --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/MyPagePostResponse.java @@ -0,0 +1,47 @@ +package com.chooz.post.presentation.dto; + +import com.chooz.common.dto.CursorDto; +import com.chooz.post.application.dto.PostWithVoteCount; +import com.chooz.post.domain.CloseOption; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.Status; + +import java.time.LocalDateTime; + +public record MyPagePostResponse( + long id, + String title, + String thumbnailImageUrl, + Status status, + CloseOptionDto closeOptionDto, + PostVoteInfo postVoteInfo, + LocalDateTime createdAt +) implements CursorDto { + + public record PostVoteInfo( + long totalVoterCount, + MostVotedPollChoiceDto mostVotedPollChoice + ) { + } + + public static MyPagePostResponse of(PostWithVoteCount postWithVoteCount, MostVotedPollChoiceDto mostVotedPollChoiceDto) { + Post post = postWithVoteCount.post(); + long totalVoterCount = postWithVoteCount.voteCount(); + CloseOption closeOption = post.getCloseOption(); + return new MyPagePostResponse( + post.getId(), + post.getTitle(), + post.getImageUrl(), + post.getStatus(), + new CloseOptionDto(closeOption.getCloseType(), closeOption.getClosedAt(), closeOption.getMaxVoterCount()), + new PostVoteInfo(totalVoterCount, mostVotedPollChoiceDto), + post.getCreatedAt() + ); + } + + @Override + public long getId() { + return id; + } + +} diff --git a/src/main/java/com/chooz/post/presentation/dto/PollChoiceRequestDto.java b/src/main/java/com/chooz/post/presentation/dto/PollChoiceRequestDto.java new file mode 100644 index 00000000..23908bb9 --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/PollChoiceRequestDto.java @@ -0,0 +1,13 @@ +package com.chooz.post.presentation.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +public record PollChoiceRequestDto( + @Size(min = 1, max = 50) + String title, + + @NotEmpty + String imageUrl +) { +} diff --git a/src/main/java/com/chooz/post/presentation/dto/PollChoiceResponse.java b/src/main/java/com/chooz/post/presentation/dto/PollChoiceResponse.java new file mode 100644 index 00000000..eb0485c3 --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/PollChoiceResponse.java @@ -0,0 +1,4 @@ +package com.chooz.post.presentation.dto; + +public record PollChoiceResponse(Long id, String title, String imageUrl) { +} diff --git a/src/main/java/com/chooz/post/presentation/dto/PollChoiceVoteResponse.java b/src/main/java/com/chooz/post/presentation/dto/PollChoiceVoteResponse.java new file mode 100644 index 00000000..c99c25c4 --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/PollChoiceVoteResponse.java @@ -0,0 +1,9 @@ +package com.chooz.post.presentation.dto; + +public record PollChoiceVoteResponse( + Long id, + String title, + String imageUrl, + Long voteId +) { +} diff --git a/src/main/java/com/chooz/post/presentation/dto/PollOptionDto.java b/src/main/java/com/chooz/post/presentation/dto/PollOptionDto.java new file mode 100644 index 00000000..b5003caf --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/PollOptionDto.java @@ -0,0 +1,18 @@ +package com.chooz.post.presentation.dto; + +import com.chooz.post.domain.CommentActive; +import com.chooz.post.domain.PollType; +import com.chooz.post.domain.Scope; +import jakarta.validation.constraints.NotNull; + +public record PollOptionDto( + @NotNull + Scope scope, + + @NotNull + PollType pollType, + + @NotNull + CommentActive commentActive +) { } + diff --git a/src/main/java/com/chooz/post/presentation/dto/PostResponse.java b/src/main/java/com/chooz/post/presentation/dto/PostResponse.java new file mode 100644 index 00000000..2a1b49d6 --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/PostResponse.java @@ -0,0 +1,62 @@ +package com.chooz.post.presentation.dto; + +import com.chooz.post.domain.CloseOption; +import com.chooz.post.domain.PollOption; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.Status; +import com.chooz.user.domain.User; + +import java.time.LocalDateTime; +import java.util.List; + +public record PostResponse( + Long id, + String title, + String description, + AuthorDto author, + List pollChoices, + String shareUrl, + boolean isAuthor, + Status status, + PollOptionDto pollOption, + CloseOptionDto closeOption, + long commentCount, + long voterCount, + LocalDateTime createdAt +) { + + public static PostResponse of( + Post post, + User user, + List pollChoices, + boolean isAuthor, + long commentCount, + long voterCount + ) { + PollOption pollOption = post.getPollOption(); + CloseOption closeOption = post.getCloseOption(); + return new PostResponse( + post.getId(), + post.getTitle(), + post.getDescription(), + AuthorDto.of(user), + pollChoices, + post.getShareUrl(), + isAuthor, + post.getStatus(), + new PollOptionDto( + pollOption.getScope(), + pollOption.getPollType(), + pollOption.getCommentActive() + ), + new CloseOptionDto( + closeOption.getCloseType(), + closeOption.getClosedAt(), + closeOption.getMaxVoterCount() + ), + commentCount, + voterCount, + post.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/chooz/post/presentation/dto/UpdatePostRequest.java b/src/main/java/com/chooz/post/presentation/dto/UpdatePostRequest.java new file mode 100644 index 00000000..ed5a1af9 --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/UpdatePostRequest.java @@ -0,0 +1,29 @@ +package com.chooz.post.presentation.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record UpdatePostRequest( + + @NotBlank + String title, + + @NotNull + String description, + + @Valid + @NotNull + List pollChoices, + + @Valid + @NotNull + PollOptionDto pollOption, + + @Valid + @NotNull + CloseOptionDto closeOption +) { +} diff --git a/src/main/java/com/chooz/post/presentation/dto/UpdatePostResponse.java b/src/main/java/com/chooz/post/presentation/dto/UpdatePostResponse.java new file mode 100644 index 00000000..e3dd4b10 --- /dev/null +++ b/src/main/java/com/chooz/post/presentation/dto/UpdatePostResponse.java @@ -0,0 +1,52 @@ +package com.chooz.post.presentation.dto; + +import com.chooz.post.domain.CloseOption; +import com.chooz.post.domain.PollOption; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.Status; + +import java.time.LocalDateTime; +import java.util.List; + +public record UpdatePostResponse( + Long id, + String title, + String description, + List pollChoices, + String shareUrl, + Status status, + PollOptionDto pollOption, + CloseOptionDto closeOption, + LocalDateTime createdAt +) { + + public static UpdatePostResponse of(Post post) { + PollOption pollOption = post.getPollOption(); + CloseOption closeOption = post.getCloseOption(); + return new UpdatePostResponse( + post.getId(), + post.getTitle(), + post.getDescription(), + post.getPollChoices() + .stream() + .map(pollChoice -> new PollChoiceResponse( + pollChoice.getId(), + pollChoice.getTitle(), + pollChoice.getImageUrl() + )).toList(), + post.getShareUrl(), + post.getStatus(), + new PollOptionDto( + pollOption.getScope(), + pollOption.getPollType(), + pollOption.getCommentActive() + ), + new CloseOptionDto( + closeOption.getCloseType(), + closeOption.getClosedAt(), + closeOption.getMaxVoterCount() + ), + post.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/chooz/thumbnail/domain/Thumbnail.java b/src/main/java/com/chooz/thumbnail/domain/Thumbnail.java new file mode 100644 index 00000000..3cb02d28 --- /dev/null +++ b/src/main/java/com/chooz/thumbnail/domain/Thumbnail.java @@ -0,0 +1,47 @@ +package com.chooz.thumbnail.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static com.chooz.common.util.Validator.validateNull; + +@Getter +@Entity +@Table(name = "thumbnails") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Thumbnail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long postId; + + private Long pollChoiceId; + + private String thumbnailUrl; + + @Builder + public Thumbnail(Long id, Long postId, Long pollChoiceId, String thumbnailUrl) { + validateNull(postId, pollChoiceId, thumbnailUrl); + this.id = id; + this.postId = postId; + this.pollChoiceId = pollChoiceId; + this.thumbnailUrl = thumbnailUrl; + } + + public static Thumbnail create(Long postId, Long pollChoiceId, String thumbnailUrl) { + return new Thumbnail(null, postId, pollChoiceId, thumbnailUrl); + } + + public boolean isThumbnailOf(Long postId) { + return this.postId.equals(postId); + } +} diff --git a/src/main/java/com/chooz/thumbnail/domain/ThumbnailRepository.java b/src/main/java/com/chooz/thumbnail/domain/ThumbnailRepository.java new file mode 100644 index 00000000..b0e59b1d --- /dev/null +++ b/src/main/java/com/chooz/thumbnail/domain/ThumbnailRepository.java @@ -0,0 +1,16 @@ +package com.chooz.thumbnail.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ThumbnailRepository extends JpaRepository { + + Optional findByPostId(Long postId); + + List findByPostIdIn(Collection postIds); +} diff --git a/src/main/java/com/chooz/user/application/NicknameGenerator.java b/src/main/java/com/chooz/user/application/NicknameGenerator.java new file mode 100644 index 00000000..158ee26e --- /dev/null +++ b/src/main/java/com/chooz/user/application/NicknameGenerator.java @@ -0,0 +1,49 @@ +package com.chooz.user.application; + +import com.chooz.user.domain.NicknameAdjectiveRepository; +import com.chooz.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import java.math.BigInteger; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +@Component +@RequiredArgsConstructor +public class NicknameGenerator { + + private final NicknameAdjectiveRepository nicknameAdjectiveRepository; + private final UserRepository userRepository; + + public String generate() { + String prefix = nicknameAdjectiveRepository.findRandomNicknameAdjective() + .map(adjective -> adjective.getAdjective() + " μΈ„") + .orElse("μˆ¨κ²¨μ§„ μΈ„"); + return makeNickname(prefix); + } + private String makeNickname(String prefix) { + List nickNames = userRepository.findNicknamesByPrefix(prefix); + Set usedSuffixes = getUsedSuffixes(prefix, nickNames); + return findUsableNickname(prefix, usedSuffixes); + } + private Set getUsedSuffixes(String prefix, List nickNames) { + Set usedSuffixes = new TreeSet<>(BigInteger::compareTo); + for(String nickName : nickNames) { + String suffix = nickName.substring(prefix.length()); + if(suffix.isEmpty()) { + usedSuffixes.add(BigInteger.ZERO); + }else{ + usedSuffixes.add(new BigInteger(suffix)); + } + } + return usedSuffixes; + } + private String findUsableNickname(String prefix, Set usedSuffixes) { + BigInteger suffix = BigInteger.ZERO; + while (usedSuffixes.contains(suffix)) { + suffix = suffix.add(BigInteger.ONE); + } + return suffix.signum() == 0 ? prefix : prefix + suffix; + } +} diff --git a/src/main/java/com/chooz/user/application/UserService.java b/src/main/java/com/chooz/user/application/UserService.java new file mode 100644 index 00000000..a840e503 --- /dev/null +++ b/src/main/java/com/chooz/user/application/UserService.java @@ -0,0 +1,78 @@ +package com.chooz.user.application; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.user.domain.UserRepository; +import com.chooz.user.domain.OnboardingStepRepository; +import com.chooz.user.domain.User; +import com.chooz.user.presentation.dto.OnboardingRequest; +import com.chooz.user.presentation.dto.UpdateUserRequest; +import com.chooz.user.presentation.dto.UserInfoResponse; +import com.chooz.user.presentation.dto.UserMyInfoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Optional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final NicknameGenerator nicknameGenerator; + private final OnboardingStepRepository onboardingStepRepository; + + @Transactional + public Long createUser(String nickname, String profileImageUrl) { + User user = userRepository.save(User.create(getOrGenerateNickname(nickname), profileImageUrl)); + return user.getId(); + } + + private String getOrGenerateNickname(String nickname) { + return Optional.ofNullable(nickname) + .orElseGet(nicknameGenerator::generate); + } + + @Transactional + public void updateUser(Long userId, UpdateUserRequest updateUserRequest) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + user.update(updateUserRequest.nickname(), updateUserRequest.profileImageUrl()); + } + + @Transactional(readOnly = true) + public UserInfoResponse findById(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + return UserInfoResponse.of(user); + } + + @Transactional(readOnly = true) + public UserMyInfoResponse findByMe(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + return UserMyInfoResponse.of(user); + } + + @Transactional + public UserInfoResponse completeStep(Long userId, OnboardingRequest onboardingRequest) { + if (onboardingRequest.onboardingStep().values().stream().noneMatch(Boolean.TRUE::equals)) { + throw new BadRequestException(ErrorCode.INVALID_ONBOARDING_STEP); + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + UpdateOnboardingStep(user, onboardingRequest); + return UserInfoResponse.of(userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND))); + } + + private void UpdateOnboardingStep(User user, OnboardingRequest onboardingRequest) { + onboardingRequest.onboardingStep().entrySet().stream() + .filter(step -> Boolean.TRUE.equals(step.getValue())) + .map(Map.Entry::getKey) + .forEach(stepType -> stepType.apply(user.getOnboardingStep())); + } +} diff --git a/src/main/java/com/swyp8team2/user/domain/NicknameAdjective.java b/src/main/java/com/chooz/user/domain/NicknameAdjective.java similarity index 94% rename from src/main/java/com/swyp8team2/user/domain/NicknameAdjective.java rename to src/main/java/com/chooz/user/domain/NicknameAdjective.java index 3a535294..f8cef4a5 100644 --- a/src/main/java/com/swyp8team2/user/domain/NicknameAdjective.java +++ b/src/main/java/com/chooz/user/domain/NicknameAdjective.java @@ -1,4 +1,4 @@ -package com.swyp8team2.user.domain; +package com.chooz.user.domain; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/com/swyp8team2/user/domain/NicknameAdjectiveRepository.java b/src/main/java/com/chooz/user/domain/NicknameAdjectiveRepository.java similarity index 93% rename from src/main/java/com/swyp8team2/user/domain/NicknameAdjectiveRepository.java rename to src/main/java/com/chooz/user/domain/NicknameAdjectiveRepository.java index ebc4e590..894bbe3f 100644 --- a/src/main/java/com/swyp8team2/user/domain/NicknameAdjectiveRepository.java +++ b/src/main/java/com/chooz/user/domain/NicknameAdjectiveRepository.java @@ -1,4 +1,4 @@ -package com.swyp8team2.user.domain; +package com.chooz.user.domain; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; diff --git a/src/main/java/com/chooz/user/domain/OnboardingStep.java b/src/main/java/com/chooz/user/domain/OnboardingStep.java new file mode 100644 index 00000000..e51e61c7 --- /dev/null +++ b/src/main/java/com/chooz/user/domain/OnboardingStep.java @@ -0,0 +1,46 @@ +package com.chooz.user.domain; + +import com.chooz.common.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@Entity +@Table(name = "onboarding_step") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class OnboardingStep extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private boolean welcomeGuide; + + private boolean firstVote; + + @Builder + public OnboardingStep(Long id, boolean welcomeGuide, boolean firstVote) { + this.id = id; + this.welcomeGuide = welcomeGuide; + this.firstVote = firstVote; + } + + public void completeWelcomeGuide() { + this.welcomeGuide = true; + } + + public void completeFirstVote() { + this.firstVote = true; + } + + public boolean isCompletedAll() { + return welcomeGuide && firstVote; + } +} diff --git a/src/main/java/com/swyp8team2/user/domain/UserRepository.java b/src/main/java/com/chooz/user/domain/OnboardingStepRepository.java similarity index 51% rename from src/main/java/com/swyp8team2/user/domain/UserRepository.java rename to src/main/java/com/chooz/user/domain/OnboardingStepRepository.java index a92bbdb2..57fe577d 100644 --- a/src/main/java/com/swyp8team2/user/domain/UserRepository.java +++ b/src/main/java/com/chooz/user/domain/OnboardingStepRepository.java @@ -1,8 +1,7 @@ -package com.swyp8team2.user.domain; +package com.chooz.user.domain; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository -public interface UserRepository extends JpaRepository { -} +public interface OnboardingStepRepository extends JpaRepository {} diff --git a/src/main/java/com/chooz/user/domain/OnboardingStepType.java b/src/main/java/com/chooz/user/domain/OnboardingStepType.java new file mode 100644 index 00000000..c96357f4 --- /dev/null +++ b/src/main/java/com/chooz/user/domain/OnboardingStepType.java @@ -0,0 +1,27 @@ +package com.chooz.user.domain; + + +import java.util.function.Consumer; +import java.util.function.Predicate; + +public enum OnboardingStepType { + + WELCOME_GUIDE(OnboardingStep::completeWelcomeGuide, OnboardingStep::isWelcomeGuide), + FIRST_VOTE(OnboardingStep::completeFirstVote, OnboardingStep::isFirstVote); + + private final Consumer action; + private final Predicate checker; + + OnboardingStepType(Consumer action, Predicate checker) { + this.action = action; + this.checker = checker; + } + + public void apply(OnboardingStep step) { + this.action.accept(step); + } + + public boolean check(OnboardingStep step) { + return this.checker.test(step); + } +} diff --git a/src/main/java/com/swyp8team2/user/domain/Role.java b/src/main/java/com/chooz/user/domain/Role.java similarity index 84% rename from src/main/java/com/swyp8team2/user/domain/Role.java rename to src/main/java/com/chooz/user/domain/Role.java index 83f409d8..15ac1054 100644 --- a/src/main/java/com/swyp8team2/user/domain/Role.java +++ b/src/main/java/com/chooz/user/domain/Role.java @@ -1,4 +1,4 @@ -package com.swyp8team2.user.domain; +package com.chooz.user.domain; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/chooz/user/domain/User.java b/src/main/java/com/chooz/user/domain/User.java new file mode 100644 index 00000000..5b75fae9 --- /dev/null +++ b/src/main/java/com/chooz/user/domain/User.java @@ -0,0 +1,81 @@ +package com.chooz.user.domain; + +import com.chooz.common.domain.BaseEntity; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +@Getter +@Entity +@Table(name = "users") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class User extends BaseEntity { + + private static final String DEFAULT_PROFILE_URL = "https://cdn.chooz.site/default_profile.png"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String nickname; + + private String profileUrl; + + private boolean notification; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "onboarding_step_id", unique = true) + private OnboardingStep onboardingStep; + + @Builder + private User( + Long id, + String nickname, + String profileUrl, + boolean notification, + OnboardingStep onboardingStep + ) { + validateNickname(nickname); + this.id = id; + this.nickname = nickname; + this.profileUrl = profileUrl; + this.notification = notification; + this.onboardingStep = onboardingStep; + } + + public static User create(String nickname, String profileUrl) { + return new User(null, nickname, getOrDefaultProfileImage(profileUrl), false, new OnboardingStep()); + } + public void update(String nickname, String profileUrl) { + validateNickname(nickname); + this.nickname = nickname; + this.profileUrl = getOrDefaultProfileImage(profileUrl); + + } + private static void validateNickname(String nickname) { + if(StringUtils.hasText(nickname) && nickname.length() > 15) { + throw new BadRequestException(ErrorCode.NICKNAME_LENGTH_EXCEEDED); + } + } + private static String getOrDefaultProfileImage(String profileImageUrl) { + return Optional.ofNullable(profileImageUrl) + .orElse(User.DEFAULT_PROFILE_URL); + } + public boolean hasCompletedOnboarding() { + return onboardingStep != null && onboardingStep.isCompletedAll(); + } +} diff --git a/src/main/java/com/chooz/user/domain/UserRepository.java b/src/main/java/com/chooz/user/domain/UserRepository.java new file mode 100644 index 00000000..c6758943 --- /dev/null +++ b/src/main/java/com/chooz/user/domain/UserRepository.java @@ -0,0 +1,20 @@ +package com.chooz.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UserRepository extends JpaRepository { + @Query(""" + SELECT u.nickname + FROM User u + WHERE u.nickname + LIKE CONCAT(:prefix, '%') + """) + List findNicknamesByPrefix(@Param("prefix") String prefix); + +} diff --git a/src/main/java/com/chooz/user/presentation/UserController.java b/src/main/java/com/chooz/user/presentation/UserController.java new file mode 100644 index 00000000..957e4604 --- /dev/null +++ b/src/main/java/com/chooz/user/presentation/UserController.java @@ -0,0 +1,56 @@ +package com.chooz.user.presentation; + +import com.chooz.auth.domain.UserInfo; +import com.chooz.user.application.UserService; +import com.chooz.user.presentation.dto.OnboardingRequest; +import com.chooz.user.presentation.dto.UpdateUserRequest; +import com.chooz.user.presentation.dto.UserInfoResponse; +import com.chooz.user.presentation.dto.UserMyInfoResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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 +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + + private final UserService userService; + + @GetMapping("/{userId}") + public ResponseEntity findUserInfo(@PathVariable("userId") Long userId) { + return ResponseEntity.ok(userService.findById(userId)); + } + + @GetMapping("/me") + public ResponseEntity findMyInfo( + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(userService.findByMe(userInfo.userId())); + } + + @PutMapping("/me") + public ResponseEntity updateMyInfo( + @AuthenticationPrincipal UserInfo userInfo, + @Valid @RequestBody UpdateUserRequest updateUserRequest + ) { + userService.updateUser(userInfo.userId(), updateUserRequest); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/onboarding") + public ResponseEntity findUserInfo( + @Valid @RequestBody OnboardingRequest request, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(userService.completeStep(userInfo.userId(), request)); + } +} diff --git a/src/main/java/com/chooz/user/presentation/dto/OnboardingRequest.java b/src/main/java/com/chooz/user/presentation/dto/OnboardingRequest.java new file mode 100644 index 00000000..e39c3ae5 --- /dev/null +++ b/src/main/java/com/chooz/user/presentation/dto/OnboardingRequest.java @@ -0,0 +1,13 @@ +package com.chooz.user.presentation.dto; + +import com.chooz.user.domain.OnboardingStepType; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.Map; + +public record OnboardingRequest( + @NotNull + @Size(min = 1) + Map onboardingStep +) {} diff --git a/src/main/java/com/chooz/user/presentation/dto/UpdateUserRequest.java b/src/main/java/com/chooz/user/presentation/dto/UpdateUserRequest.java new file mode 100644 index 00000000..cf0374d9 --- /dev/null +++ b/src/main/java/com/chooz/user/presentation/dto/UpdateUserRequest.java @@ -0,0 +1,11 @@ +package com.chooz.user.presentation.dto; + +import jakarta.validation.constraints.NotBlank; + +public record UpdateUserRequest( + @NotBlank + String nickname, + + String profileImageUrl +) {} + diff --git a/src/main/java/com/chooz/user/presentation/dto/UserInfoResponse.java b/src/main/java/com/chooz/user/presentation/dto/UserInfoResponse.java new file mode 100644 index 00000000..2f7d4315 --- /dev/null +++ b/src/main/java/com/chooz/user/presentation/dto/UserInfoResponse.java @@ -0,0 +1,36 @@ +package com.chooz.user.presentation.dto; + +import com.chooz.user.domain.OnboardingStep; +import com.chooz.user.domain.OnboardingStepType; +import com.chooz.user.domain.User; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public record UserInfoResponse( + Long id, + String nickname, + String profileImageUrl, + boolean notification, + Map onboardingStep + +) { + public static UserInfoResponse of(User user) { + return new UserInfoResponse( + user.getId(), + user.getNickname(), + user.getProfileUrl(), + user.isNotification(), + convertStepStatus(user.getOnboardingStep()) + ); + } + + private static Map convertStepStatus(OnboardingStep step) { + return Arrays.stream(OnboardingStepType.values()) + .collect(Collectors.toMap( + Enum::name, + stepType -> step != null && stepType.check(step) + )); + } +} diff --git a/src/main/java/com/chooz/user/presentation/dto/UserMyInfoResponse.java b/src/main/java/com/chooz/user/presentation/dto/UserMyInfoResponse.java new file mode 100644 index 00000000..d3981641 --- /dev/null +++ b/src/main/java/com/chooz/user/presentation/dto/UserMyInfoResponse.java @@ -0,0 +1,36 @@ +package com.chooz.user.presentation.dto; + +import com.chooz.user.domain.OnboardingStep; +import com.chooz.user.domain.OnboardingStepType; +import com.chooz.user.domain.User; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public record UserMyInfoResponse( + Long id, + String nickname, + String profileImageUrl, + boolean notification, + Map onboardingStep + +) { + public static UserMyInfoResponse of(User user) { + return new UserMyInfoResponse( + user.getId(), + user.getNickname(), + user.getProfileUrl(), + user.isNotification(), + convertStepStatus(user.getOnboardingStep()) + ); + } + + private static Map convertStepStatus(OnboardingStep step) { + return Arrays.stream(OnboardingStepType.values()) + .collect(Collectors.toMap( + Enum::name, + stepType -> step != null && stepType.check(step) + )); + } +} diff --git a/src/main/java/com/swyp8team2/vote/application/RatioCalculator.java b/src/main/java/com/chooz/vote/application/RatioCalculator.java similarity index 51% rename from src/main/java/com/swyp8team2/vote/application/RatioCalculator.java rename to src/main/java/com/chooz/vote/application/RatioCalculator.java index 3b7e954b..f7a6b748 100644 --- a/src/main/java/com/swyp8team2/vote/application/RatioCalculator.java +++ b/src/main/java/com/chooz/vote/application/RatioCalculator.java @@ -1,4 +1,4 @@ -package com.swyp8team2.vote.application; +package com.chooz.vote.application; import org.springframework.stereotype.Component; @@ -8,14 +8,18 @@ @Component public class RatioCalculator { - public String calculate(int totalVoteCount, int voteCount) { + public String calculate(long totalVoteCount, long voteCount) { if (totalVoteCount == 0) { - return "0.0"; + return "0"; } BigDecimal totalCount = new BigDecimal(totalVoteCount); BigDecimal count = new BigDecimal(voteCount); - BigDecimal bigDecimal = count.divide(totalCount, 3, RoundingMode.HALF_UP) + BigDecimal bigDecimal = count.divide(totalCount, 2, RoundingMode.HALF_UP) .multiply(new BigDecimal(100)); - return String.format("%.1f", bigDecimal); + return String.valueOf(bigDecimal.intValue()); + } + + public String calculate(int totalVoteCount, long voteCount) { + return calculate((long) totalVoteCount, voteCount); } } diff --git a/src/main/java/com/chooz/vote/application/VoteResultReader.java b/src/main/java/com/chooz/vote/application/VoteResultReader.java new file mode 100644 index 00000000..d1f4c1c7 --- /dev/null +++ b/src/main/java/com/chooz/vote/application/VoteResultReader.java @@ -0,0 +1,53 @@ +package com.chooz.vote.application; + +import com.chooz.post.domain.PollChoice; +import com.chooz.post.domain.Post; +import com.chooz.vote.domain.Vote; +import com.chooz.vote.presentation.dto.VoteResultResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class VoteResultReader { + + private final RatioCalculator ratioCalculator; + + public List getVoteResult(List totalVoteList, Post post) { + int totalVoteCount = totalVoteList.size(); + Map pollChoiceVoteCountMap = getPollChoiceVoteCountMap(totalVoteList, post); + return pollChoiceVoteCountMap.entrySet().stream() + .map(entry -> getVoteResultResponse(entry, totalVoteCount)) + .sorted(Comparator.comparing(VoteResultResponse::voteCount, Comparator.reverseOrder()) + .thenComparing(VoteResultResponse::id)) + .toList(); + } + + private Map getPollChoiceVoteCountMap(List totalVoteList, Post post) { + return post.getPollChoices().stream() + .collect(Collectors.toMap( + pollChoice -> pollChoice, + pollChoice -> totalVoteList.stream() + .filter(vote -> vote.getPollChoiceId().equals(pollChoice.getId())) + .count() + )); + } + + private VoteResultResponse getVoteResultResponse(Map.Entry entry, int totalVoteCount) { + PollChoice pollChoice = entry.getKey(); + Long voteCount = entry.getValue(); + String ratio = ratioCalculator.calculate(totalVoteCount, voteCount); + return new VoteResultResponse( + pollChoice.getId(), + pollChoice.getTitle(), + pollChoice.getImageUrl(), + voteCount, + ratio + ); + } +} diff --git a/src/main/java/com/chooz/vote/application/VoteService.java b/src/main/java/com/chooz/vote/application/VoteService.java new file mode 100644 index 00000000..06fb4984 --- /dev/null +++ b/src/main/java/com/chooz/vote/application/VoteService.java @@ -0,0 +1,52 @@ +package com.chooz.vote.application; + +import com.chooz.common.event.EventPublisher; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.vote.domain.Vote; +import com.chooz.vote.domain.VoteRepository; +import com.chooz.vote.presentation.dto.VoteResultResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class VoteService { + + private final VoteRepository voteRepository; + private final PostRepository postRepository; + private final EventPublisher eventPublisher; + private final VoteValidator voteValidator; + private final VoteResultReader voteResultReader; + private final VoteWriter voteWriter; + + @Transactional + public List vote(Long voterId, Long postId, List pollChoiceIds) { + Post post = postRepository.findByIdFetchPollChoicesWithLock(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + + voteValidator.validateIsVotable(post, pollChoiceIds); + + List voteIds = voteWriter.vote(voterId, postId, pollChoiceIds); + + eventPublisher.publish(new VotedEvent(post.getId(), pollChoiceIds, voterId)); + eventPublisher.publish(new VotedNotificationEvent(postId, voterId, LocalDateTime.now())); + return voteIds; + } + + public List findVoteResult(Long userId, Long postId) { + Post post = postRepository.findByIdFetchPollChoices(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + List totalVoteList = voteRepository.findAllByPostId(postId); + voteValidator.validateVoteResultAccess(userId, post, totalVoteList); + + return voteResultReader.getVoteResult(totalVoteList, post); + } +} diff --git a/src/main/java/com/chooz/vote/application/VoteValidator.java b/src/main/java/com/chooz/vote/application/VoteValidator.java new file mode 100644 index 00000000..718b796a --- /dev/null +++ b/src/main/java/com/chooz/vote/application/VoteValidator.java @@ -0,0 +1,91 @@ +package com.chooz.vote.application; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.CloseOption; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.PollChoice; +import com.chooz.post.domain.Post; +import com.chooz.vote.domain.Vote; +import com.chooz.vote.domain.VoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class VoteValidator { + + private final VoteRepository voteRepository; + private final Clock clock; + + public void validateIsVotable(Post post, List pollChoiceIds) { + validatePost(post); + if (post.isSingleVote()) { + validateSingleVote(pollChoiceIds); + } else { + validateMultipleVotes(pollChoiceIds); + } + validatePollChoiceId(post, pollChoiceIds); + } + + private void validatePost(Post post) { + CloseOption closeOption = post.getCloseOption(); + CloseType closeType = closeOption.getCloseType(); + + post.validateProgress(); + switch (closeType) { + case DATE -> post.validateCloseDate(clock); + case VOTER -> { + long voterCount = voteRepository.countVoterByPostId(post.getId()); + post.validateMaxVoterCount(voterCount); + } + } + } + + private void validateSingleVote(List pollChoiceIds) { + if (pollChoiceIds.size() > 1) { + throw new BadRequestException(ErrorCode.SINGLE_POLL_ALLOWS_MAXIMUM_ONE_CHOICE); + } + } + + private void validateMultipleVotes(List pollChoiceIds) { + if (pollChoiceIds.size() != pollChoiceIds.stream().distinct().count()) { + throw new BadRequestException(ErrorCode.DUPLICATE_POLL_CHOICE); + } + } + + private void validatePollChoiceId(Post post, List pollChoiceIds) { + if (pollChoiceIds.isEmpty()) { + return; + } + List existingPollChoiceIds = post.getPollChoices() + .stream() + .map(PollChoice::getId) + .toList(); + + boolean hasInvalidChoiceId = pollChoiceIds.stream() + .noneMatch(existingPollChoiceIds::contains); + + if (hasInvalidChoiceId) { + throw new BadRequestException(ErrorCode.NOT_POST_POLL_CHOICE_ID); + } + } + + public void validateVoteResultAccess(Long userId, Post post, List totalVoteList) { + if (post.isClosed()) { + return; + } + validateNotClosedPostVoteStatusAccess(userId, post, totalVoteList); + } + + private void validateNotClosedPostVoteStatusAccess(Long userId, Post post, List totalVoteList) { + boolean voted = totalVoteList.stream() + .anyMatch(vote -> vote.getUserId().equals(userId)); + if (!(post.isAuthor(userId) || voted)) { + throw new BadRequestException(ErrorCode.ACCESS_DENIED_VOTE_STATUS); + } + } +} diff --git a/src/main/java/com/chooz/vote/application/VoteWriter.java b/src/main/java/com/chooz/vote/application/VoteWriter.java new file mode 100644 index 00000000..d7f46f20 --- /dev/null +++ b/src/main/java/com/chooz/vote/application/VoteWriter.java @@ -0,0 +1,64 @@ +package com.chooz.vote.application; + +import com.chooz.common.domain.BaseEntity; +import com.chooz.vote.domain.Vote; +import com.chooz.vote.domain.VoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; + +@Component +@Transactional +@RequiredArgsConstructor +public class VoteWriter { + + private final VoteRepository voteRepository; + + public List vote(Long voterId, Long postId, List pollChoiceIds) { + List existingVotes = voteRepository.findByUserIdAndPostId(voterId, postId); + + List newVotes = createNewVotes(voterId, postId, pollChoiceIds, existingVotes); + + deleteUnselectedVotes(pollChoiceIds, existingVotes); + + return getTotalVoteIds(existingVotes, newVotes); + } + + private void deleteUnselectedVotes(List pollChoiceIds, List existingVotes) { + existingVotes.stream() + .filter(existingVote -> isUnselectedVote(pollChoiceIds, existingVote)) + .forEach(BaseEntity::delete); + } + + private boolean isUnselectedVote(List pollChoiceIds, Vote existingVote) { + return pollChoiceIds.stream().noneMatch(pollChoiceId -> pollChoiceId.equals(existingVote.getPollChoiceId())); + } + + private List createNewVotes(Long voterId, Long postId, List pollChoiceIds, List existingVotes) { + List newVotePollChoiceIds = pollChoiceIds.stream() + .filter(pollChoiceId -> isNewVotePollChoiceId(pollChoiceId, existingVotes)) + .toList(); + + return newVotePollChoiceIds.stream() + .map(pollChoiceId -> voteRepository.save(Vote.create(voterId, postId, pollChoiceId))) + .toList(); + } + + private boolean isNewVotePollChoiceId(Long pollChoiceId, List existingVotes) { + return existingVotes.stream() + .noneMatch(v -> v.getPollChoiceId().equals(pollChoiceId)); + } + + private List getTotalVoteIds(List existingVotes, List newVotes) { + return Stream.of(existingVotes, newVotes) + .flatMap(Collection::stream) + .map(Vote::getId) + .distinct() + .toList(); + } + +} diff --git a/src/main/java/com/chooz/vote/application/VotedEvent.java b/src/main/java/com/chooz/vote/application/VotedEvent.java new file mode 100644 index 00000000..03e2006f --- /dev/null +++ b/src/main/java/com/chooz/vote/application/VotedEvent.java @@ -0,0 +1,6 @@ +package com.chooz.vote.application; + +import java.util.List; + +public record VotedEvent(Long postId, List pollChoiceIds, Long voterId) { +} diff --git a/src/main/java/com/chooz/vote/application/VotedNotificationEvent.java b/src/main/java/com/chooz/vote/application/VotedNotificationEvent.java new file mode 100644 index 00000000..ee209d46 --- /dev/null +++ b/src/main/java/com/chooz/vote/application/VotedNotificationEvent.java @@ -0,0 +1,10 @@ +package com.chooz.vote.application; + +import java.time.LocalDateTime; + +public record VotedNotificationEvent( + Long postId, + Long voterId, + LocalDateTime eventAt +) {} + diff --git a/src/main/java/com/chooz/vote/domain/Vote.java b/src/main/java/com/chooz/vote/domain/Vote.java new file mode 100644 index 00000000..f844b930 --- /dev/null +++ b/src/main/java/com/chooz/vote/domain/Vote.java @@ -0,0 +1,53 @@ +package com.chooz.vote.domain; + +import com.chooz.common.domain.BaseEntity; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "user_votes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Vote extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long postId; + + private Long pollChoiceId; + + private Long userId; + + @Builder + private Vote(Long id, Long postId, Long pollChoiceId, Long userId) { + this.id = id; + this.postId = postId; + this.pollChoiceId = pollChoiceId; + this.userId = userId; + } + + public static Vote create(Long userId, Long postId, Long pollChoiceId) { + return new Vote(null, postId, pollChoiceId, userId); + } + + public void validateVoter(Long userId) { + if (!this.userId.equals(userId)) { + throw new BadRequestException(ErrorCode.NOT_VOTER); + } + } + + public void updatePollChoiceId(Long pollChoiceId) { + this.pollChoiceId = pollChoiceId; + } +} diff --git a/src/main/java/com/chooz/vote/domain/VoteRepository.java b/src/main/java/com/chooz/vote/domain/VoteRepository.java new file mode 100644 index 00000000..ecef0f16 --- /dev/null +++ b/src/main/java/com/chooz/vote/domain/VoteRepository.java @@ -0,0 +1,17 @@ +package com.chooz.vote.domain; + +import java.util.List; + +public interface VoteRepository { + + Vote save(Vote vote); + + List findByUserIdAndPostId(Long userId, Long postId); + + List findAllByPostId(Long postId); + + List findByPostIdAndDeletedFalse(Long id); + + long countVoterByPostId(Long postId); + +} diff --git a/src/main/java/com/chooz/vote/persistence/VoteJpaRepository.java b/src/main/java/com/chooz/vote/persistence/VoteJpaRepository.java new file mode 100644 index 00000000..e44a0a1e --- /dev/null +++ b/src/main/java/com/chooz/vote/persistence/VoteJpaRepository.java @@ -0,0 +1,19 @@ +package com.chooz.vote.persistence; + +import com.chooz.vote.domain.Vote; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface VoteJpaRepository extends JpaRepository { + List findByUserIdAndPostIdAndDeletedFalse(Long userId, Long postId); + + List findAllByPostIdAndDeletedFalse(Long postId); + + List findByPostIdAndDeletedFalse(Long id); + +} diff --git a/src/main/java/com/chooz/vote/persistence/VoteQueryDslRepository.java b/src/main/java/com/chooz/vote/persistence/VoteQueryDslRepository.java new file mode 100644 index 00000000..2e509621 --- /dev/null +++ b/src/main/java/com/chooz/vote/persistence/VoteQueryDslRepository.java @@ -0,0 +1,23 @@ +package com.chooz.vote.persistence; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import static com.chooz.vote.domain.QVote.vote; + +@Repository +@RequiredArgsConstructor +public class VoteQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + public Long countVoterByPostId(Long postId) { + return queryFactory.select(vote.userId.countDistinct()) + .from(vote) + .where(vote.postId.eq(postId)) + .where(vote.deleted.eq(false)) + .fetchOne(); + } + +} diff --git a/src/main/java/com/chooz/vote/persistence/VoteRepositoryImpl.java b/src/main/java/com/chooz/vote/persistence/VoteRepositoryImpl.java new file mode 100644 index 00000000..90afda0e --- /dev/null +++ b/src/main/java/com/chooz/vote/persistence/VoteRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.chooz.vote.persistence; + +import com.chooz.vote.domain.Vote; +import com.chooz.vote.domain.VoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class VoteRepositoryImpl implements VoteRepository { + + private final VoteJpaRepository voteRepository; + private final VoteQueryDslRepository voteQueryDslRepository; + + @Override + public Vote save(Vote vote) { + return voteRepository.save(vote); + } + + @Override + public List findByUserIdAndPostId(Long userId, Long postId) { + return voteRepository.findByUserIdAndPostIdAndDeletedFalse(userId, postId); + } + + @Override + public List findAllByPostId(Long postId) { + return voteRepository.findAllByPostIdAndDeletedFalse(postId); + } + + @Override + public List findByPostIdAndDeletedFalse(Long id) { + return voteRepository.findByPostIdAndDeletedFalse(id); + } + + @Override + public long countVoterByPostId(Long postId) { + return voteQueryDslRepository.countVoterByPostId(postId); + } +} diff --git a/src/main/java/com/swyp8team2/vote/presentation/VoteController.java b/src/main/java/com/chooz/vote/presentation/VoteController.java similarity index 50% rename from src/main/java/com/swyp8team2/vote/presentation/VoteController.java rename to src/main/java/com/chooz/vote/presentation/VoteController.java index 058e0bc7..a3c7a433 100644 --- a/src/main/java/com/swyp8team2/vote/presentation/VoteController.java +++ b/src/main/java/com/chooz/vote/presentation/VoteController.java @@ -1,14 +1,13 @@ -package com.swyp8team2.vote.presentation; +package com.chooz.vote.presentation; -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.vote.presentation.dto.PostImageVoteStatusResponse; -import com.swyp8team2.vote.application.VoteService; -import com.swyp8team2.vote.presentation.dto.VoteRequest; +import com.chooz.auth.domain.UserInfo; +import com.chooz.vote.presentation.dto.VoteResultResponse; +import com.chooz.vote.application.VoteService; +import com.chooz.vote.presentation.dto.VoteRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -23,30 +22,20 @@ public class VoteController { private final VoteService voteService; - @PostMapping("/posts/{postId}/votes") + @PostMapping("/votes") public ResponseEntity vote( - @PathVariable("postId") Long postId, @Valid @RequestBody VoteRequest request, @AuthenticationPrincipal UserInfo userInfo ) { - voteService.vote(userInfo.userId(), postId, request.imageId()); - return ResponseEntity.ok().build(); - } - - @DeleteMapping("/votes/{voteId}") - public ResponseEntity cancelVote( - @PathVariable("voteId") Long voteId, - @AuthenticationPrincipal UserInfo userInfo - ) { - voteService.cancelVote(userInfo.userId(), voteId); + voteService.vote(userInfo.userId(), request.postId(), request.pollChoiceIds()); return ResponseEntity.ok().build(); } - @GetMapping("/posts/{postId}/votes/status") - public ResponseEntity> findVoteStatus( + @GetMapping("/posts/{postId}/votes/result") + public ResponseEntity> findVoteResult( @PathVariable("postId") Long postId, @AuthenticationPrincipal UserInfo userInfo ) { - return ResponseEntity.ok(voteService.findVoteStatus(userInfo.userId(), postId)); + return ResponseEntity.ok(voteService.findVoteResult(userInfo.userId(), postId)); } } diff --git a/src/main/java/com/swyp8team2/vote/presentation/dto/ChangeVoteRequest.java b/src/main/java/com/chooz/vote/presentation/dto/ChangeVoteRequest.java similarity index 73% rename from src/main/java/com/swyp8team2/vote/presentation/dto/ChangeVoteRequest.java rename to src/main/java/com/chooz/vote/presentation/dto/ChangeVoteRequest.java index f9c102f8..e52a31f9 100644 --- a/src/main/java/com/swyp8team2/vote/presentation/dto/ChangeVoteRequest.java +++ b/src/main/java/com/chooz/vote/presentation/dto/ChangeVoteRequest.java @@ -1,4 +1,4 @@ -package com.swyp8team2.vote.presentation.dto; +package com.chooz.vote.presentation.dto; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/swyp8team2/vote/presentation/dto/GuestVoteRequest.java b/src/main/java/com/chooz/vote/presentation/dto/GuestVoteRequest.java similarity index 77% rename from src/main/java/com/swyp8team2/vote/presentation/dto/GuestVoteRequest.java rename to src/main/java/com/chooz/vote/presentation/dto/GuestVoteRequest.java index 60492cc1..690c7690 100644 --- a/src/main/java/com/swyp8team2/vote/presentation/dto/GuestVoteRequest.java +++ b/src/main/java/com/chooz/vote/presentation/dto/GuestVoteRequest.java @@ -1,4 +1,4 @@ -package com.swyp8team2.vote.presentation.dto; +package com.chooz.vote.presentation.dto; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/chooz/vote/presentation/dto/VoteRequest.java b/src/main/java/com/chooz/vote/presentation/dto/VoteRequest.java new file mode 100644 index 00000000..831122cb --- /dev/null +++ b/src/main/java/com/chooz/vote/presentation/dto/VoteRequest.java @@ -0,0 +1,14 @@ +package com.chooz.vote.presentation.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record VoteRequest( + @NotNull + Long postId, + + @NotNull + List pollChoiceIds +) { +} diff --git a/src/main/java/com/chooz/vote/presentation/dto/VoteResultResponse.java b/src/main/java/com/chooz/vote/presentation/dto/VoteResultResponse.java new file mode 100644 index 00000000..883acc15 --- /dev/null +++ b/src/main/java/com/chooz/vote/presentation/dto/VoteResultResponse.java @@ -0,0 +1,10 @@ +package com.chooz.vote.presentation.dto; + +public record VoteResultResponse( + Long id, + String title, + String imageUrl, + long voteCount, + String voteRatio +) { +} diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java b/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java deleted file mode 100644 index 08048542..00000000 --- a/src/main/java/com/swyp8team2/auth/presentation/dto/AuthResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.swyp8team2.auth.presentation.dto; - -public record AuthResponse(String accessToken, Long userId, com.swyp8team2.user.domain.Role role) { -} diff --git a/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java b/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java deleted file mode 100644 index dc29f892..00000000 --- a/src/main/java/com/swyp8team2/auth/presentation/dto/TokenResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.swyp8team2.auth.presentation.dto; - -import com.swyp8team2.auth.application.jwt.TokenPair; -import com.swyp8team2.user.domain.Role; - -public record TokenResponse( - TokenPair tokenPair, - Long userId, - Role role -) { } diff --git a/src/main/java/com/swyp8team2/comment/application/CommentService.java b/src/main/java/com/swyp8team2/comment/application/CommentService.java deleted file mode 100644 index 85147560..00000000 --- a/src/main/java/com/swyp8team2/comment/application/CommentService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.swyp8team2.comment.application; - -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.comment.domain.Comment; -import com.swyp8team2.comment.domain.CommentRepository; -import com.swyp8team2.comment.presentation.dto.CommentResponse; -import com.swyp8team2.comment.presentation.dto.CommentRequest; -import com.swyp8team2.common.dto.CursorBasePaginatedResponse; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.ForbiddenException; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import com.swyp8team2.vote.domain.Vote; -import com.swyp8team2.vote.domain.VoteRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.stream.Collectors; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Slf4j -public class CommentService { - - private final UserRepository userRepository; - private final CommentRepository commentRepository; - private final VoteRepository voteRepository; - - @Transactional - public void createComment(Long postId, CommentRequest request, UserInfo userInfo) { - Comment comment = new Comment(postId, userInfo.userId(), request.content()); - commentRepository.save(comment); - } - - public CursorBasePaginatedResponse findComments(Long userId, Long postId, Long cursor, int size) { - Slice commentSlice = commentRepository.findByPostId(postId, cursor, PageRequest.ofSize(size)); - return CursorBasePaginatedResponse.of( - commentSlice.map(comment -> createCommentResponse(comment, userId)) - ); - } - - private CommentResponse createCommentResponse(Comment comment, Long userId) { - User author = userRepository.findById(comment.getUserNo()) - .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); - List votes = voteRepository.findByUserIdAndPostId(userId, comment.getPostId()); - List voteImageIds = votes.stream() - .map(Vote::getPostImageId) - .collect(Collectors.toList()); - return CommentResponse.of(comment, author, author.getId().equals(userId), voteImageIds); - } - - @Transactional - public void updateComment(Long commentId, CommentRequest request, UserInfo userInfo) { - Comment comment = commentRepository.findByIdAndNotDeleted(commentId) - .orElseThrow(() -> new BadRequestException(ErrorCode.COMMENT_NOT_FOUND)); - - if (!comment.getUserNo().equals(userInfo.userId())) { - throw new ForbiddenException(); - } - - comment.updateComment(request.content()); - } - - @Transactional - public void deleteComment(Long commentId, UserInfo userInfo) { - Comment comment = commentRepository.findByIdAndNotDeleted(commentId) - .orElseThrow(() -> new BadRequestException(ErrorCode.COMMENT_NOT_FOUND)); - - if (!comment.getUserNo().equals(userInfo.userId())) { - throw new ForbiddenException(); - } - - comment.delete(); - } -} diff --git a/src/main/java/com/swyp8team2/comment/domain/Comment.java b/src/main/java/com/swyp8team2/comment/domain/Comment.java deleted file mode 100644 index c02b1ebe..00000000 --- a/src/main/java/com/swyp8team2/comment/domain/Comment.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.swyp8team2.comment.domain; - -import com.swyp8team2.common.domain.BaseEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.GenerationType; -import jakarta.validation.constraints.NotNull; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import static com.swyp8team2.common.util.Validator.validateEmptyString; -import static com.swyp8team2.common.util.Validator.validateNull; - -@Entity -@Getter -@Table(name = "comments") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Comment extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @NotNull - private Long postId; - - @NotNull - private Long userNo; - - @NotNull - private String content; - - public Comment(Long id, Long postId, Long userNo, String content) { - validateNull(postId, userNo); - validateEmptyString(content); - this.id = id; - this.postId = postId; - this.userNo = userNo; - this.content = content; - } - - public Comment(Long postId, Long userNo, String content) { - validateNull(postId, userNo); - validateEmptyString(content); - this.postId = postId; - this.userNo = userNo; - this.content = content; - } - - public void updateComment(String content) { - validateEmptyString(content); - this.content = content; - } -} diff --git a/src/main/java/com/swyp8team2/comment/domain/CommentRepository.java b/src/main/java/com/swyp8team2/comment/domain/CommentRepository.java deleted file mode 100644 index 6f0e5d47..00000000 --- a/src/main/java/com/swyp8team2/comment/domain/CommentRepository.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.swyp8team2.comment.domain; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface CommentRepository extends JpaRepository { - - @Query(""" - SELECT c - FROM Comment c - WHERE c.postId = :postId - AND c.deleted = false - AND (:cursor is null or c.id > :cursor) - ORDER BY c.createdAt ASC - """) - Slice findByPostId( - @Param("postId") Long postId, - @Param("cursor") Long cursor, - Pageable pageable - ); - - @Query(""" - SELECT c - FROM Comment c - WHERE c.id = :commentId - AND c.deleted = false - """) - Optional findByIdAndNotDeleted(@Param("commentId") Long commentId); - - List findByPostIdAndDeletedFalse(Long postId); -} diff --git a/src/main/java/com/swyp8team2/comment/presentation/CommentController.java b/src/main/java/com/swyp8team2/comment/presentation/CommentController.java deleted file mode 100644 index e57d691f..00000000 --- a/src/main/java/com/swyp8team2/comment/presentation/CommentController.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.swyp8team2.comment.presentation; - -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.comment.application.CommentService; -import com.swyp8team2.comment.presentation.dto.CommentResponse; -import com.swyp8team2.comment.presentation.dto.CommentRequest; -import com.swyp8team2.common.dto.CursorBasePaginatedResponse; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Optional; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/posts/{postId}/comments") -public class CommentController { - - private final CommentService commentService; - - @PostMapping("") - public ResponseEntity createComment( - @PathVariable("postId") Long postId, - @Valid @RequestBody CommentRequest request, - @AuthenticationPrincipal UserInfo userInfo - ) { - commentService.createComment(postId, request, userInfo); - return ResponseEntity.ok().build(); - } - - @GetMapping("") - public ResponseEntity> selectComments( - @PathVariable("postId") Long postId, - @RequestParam(value = "cursor", required = false) @Min(0) Long cursor, - @RequestParam(value = "size", required = false, defaultValue = "10") @Min(1) int size, - @AuthenticationPrincipal UserInfo userInfo - ) { - Long userId = Optional.ofNullable(userInfo).map(UserInfo::userId).orElse(null); - return ResponseEntity.ok(commentService.findComments(userId, postId, cursor, size)); - } - - @PostMapping("/{commentId}") - public ResponseEntity updateComment( - @PathVariable("postId") Long postId, - @PathVariable("commentId") Long commentId, - @Valid @RequestBody CommentRequest request, - @AuthenticationPrincipal UserInfo userInfo - ) { - commentService.updateComment(commentId, request, userInfo); - return ResponseEntity.ok().build(); - } - - @DeleteMapping("/{commentId}") - public ResponseEntity deleteComment( - @PathVariable("postId") Long postId, - @PathVariable("commentId") Long commentId, - @AuthenticationPrincipal UserInfo userInfo - ) { - commentService.deleteComment(commentId, userInfo); - return ResponseEntity.ok().build(); - } -} diff --git a/src/main/java/com/swyp8team2/comment/presentation/dto/AuthorDto.java b/src/main/java/com/swyp8team2/comment/presentation/dto/AuthorDto.java deleted file mode 100644 index 69cff2d9..00000000 --- a/src/main/java/com/swyp8team2/comment/presentation/dto/AuthorDto.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.swyp8team2.comment.presentation.dto; - -public record AuthorDto( - Long userId, - String nickname, - String profileUrl -) { -} diff --git a/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java b/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java deleted file mode 100644 index 4a238493..00000000 --- a/src/main/java/com/swyp8team2/comment/presentation/dto/CommentResponse.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.swyp8team2.comment.presentation.dto; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.swyp8team2.comment.domain.Comment; -import com.swyp8team2.common.dto.CursorDto; -import com.swyp8team2.user.domain.User; - -import java.time.LocalDateTime; -import java.util.List; - -public record CommentResponse( - Long commentId, - String content, - AuthorDto author, - List voteImageId, - LocalDateTime createdAt, - boolean isAuthor -) implements CursorDto { - - @Override - @JsonIgnore - public long getId() { - return commentId; - } - - public static CommentResponse of(Comment comment, User user, boolean isAuthor, List voteImageId) { - return new CommentResponse(comment.getId(), - comment.getContent(), - new AuthorDto(user.getId(), user.getNickname(), user.getProfileUrl()), - voteImageId, - comment.getCreatedAt(), - isAuthor - ); - } -} diff --git a/src/main/java/com/swyp8team2/common/config/CommonConfig.java b/src/main/java/com/swyp8team2/common/config/CommonConfig.java deleted file mode 100644 index efd9c6a0..00000000 --- a/src/main/java/com/swyp8team2/common/config/CommonConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.swyp8team2.common.config; - -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.time.Clock; - -@Configuration -@ConfigurationPropertiesScan -public class CommonConfig { - - - @Bean - public Clock clock() { - return Clock.systemDefaultZone(); - } -} diff --git a/src/main/java/com/swyp8team2/common/dev/DataInitializer.java b/src/main/java/com/swyp8team2/common/dev/DataInitializer.java deleted file mode 100644 index 21b6f01b..00000000 --- a/src/main/java/com/swyp8team2/common/dev/DataInitializer.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.swyp8team2.common.dev; - -import com.swyp8team2.auth.application.jwt.JwtClaim; -import com.swyp8team2.auth.application.jwt.JwtService; -import com.swyp8team2.auth.application.jwt.TokenPair; -import com.swyp8team2.auth.presentation.dto.TokenResponse; -import com.swyp8team2.comment.domain.Comment; -import com.swyp8team2.comment.domain.CommentRepository; -import com.swyp8team2.post.application.ShareUrlService; -import com.swyp8team2.image.domain.ImageFile; -import com.swyp8team2.image.domain.ImageFileRepository; -import com.swyp8team2.image.presentation.dto.ImageFileDto; -import com.swyp8team2.post.domain.Post; -import com.swyp8team2.post.domain.PostImage; -import com.swyp8team2.post.domain.PostRepository; -import com.swyp8team2.post.domain.Scope; -import com.swyp8team2.post.domain.VoteType; -import com.swyp8team2.user.domain.NicknameAdjective; -import com.swyp8team2.user.domain.NicknameAdjectiveRepository; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import com.swyp8team2.vote.application.VoteService; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; - -@Profile({"dev", "local"}) -@Component -public class DataInitializer { - - private final NicknameAdjectiveRepository nicknameAdjectiveRepository; - private final UserRepository userRepository; - private final ImageFileRepository imageFileRepository; - private final PostRepository postRepository; - private final ShareUrlService shaereUrlShareUrlService; - private final JwtService jwtService; - private final VoteService voteService; - private final CommentRepository commentRepository; - - public DataInitializer( - NicknameAdjectiveRepository nicknameAdjectiveRepository, - UserRepository userRepository, - ImageFileRepository imageFileRepository, - PostRepository postRepository, - ShareUrlService shaereUrlShareUrlService, - JwtService jwtService, - VoteService voteService, - CommentRepository commentRepository - ) { - this.nicknameAdjectiveRepository = nicknameAdjectiveRepository; - this.userRepository = userRepository; - this.imageFileRepository = imageFileRepository; - this.postRepository = postRepository; - this.shaereUrlShareUrlService = shaereUrlShareUrlService; - this.jwtService = jwtService; - this.voteService = voteService; - this.commentRepository = commentRepository; - } - - - @Transactional - public void init() { - if (userRepository.count() > 0) { - return; - } - List adjectives = nicknameAdjectiveRepository.findAll(); - User testUser = userRepository.save(User.create("nickname", "https://t1.kakaocdn.net/account_images/default_profile.jpeg")); - TokenResponse tokenResponse = jwtService.createToken(new JwtClaim(testUser.getId(), testUser.getRole())); - TokenPair tokenPair = tokenResponse.tokenPair(); - System.out.println("accessToken = " + tokenPair.accessToken()); - System.out.println("refreshToken = " + tokenPair.refreshToken()); - List users = new ArrayList<>(); - List posts = new ArrayList<>(); - for (int i = 0; i < 10; i++) { - String userName = adjectives.size() < 10 ? "user" + i : adjectives.get(i).getAdjective(); - User user = userRepository.save(User.create(userName, "https://t1.kakaocdn.net/account_images/default_profile.jpeg")); - users.add(user); - for (int j = 0; j < 30; j += 2) { - ImageFile imageFile1 = imageFileRepository.save(ImageFile.create(new ImageFileDto("202502240006030.png", "https://image.photopic.site/images-dev/202502240006030.png", "https://image.photopic.site/images-dev/resized_202502240006030.png"))); - ImageFile imageFile2 = imageFileRepository.save(ImageFile.create(new ImageFileDto("202502240006030.png", "https://image.photopic.site/images-dev/202502240006030.png", "https://image.photopic.site/images-dev/resized_202502240006030.png"))); - Post post = postRepository.save(Post.create(user.getId(), "description" + j, List.of(PostImage.create("λ½€λ˜A", imageFile1.getId()), PostImage.create("λ½€λ˜B", imageFile2.getId())), Scope.PUBLIC, VoteType.SINGLE)); - post.setShareUrl(shaereUrlShareUrlService.encrypt(String.valueOf(post.getId()))); - posts.add(post); - } - - } - for (User user : users) { - for (Post post : posts) { - Random random = new Random(); - int num = random.nextInt(2); - voteService.vote(user.getId(), post.getId(), post.getImages().get(num).getId()); - commentRepository.save(new Comment(post.getId(), user.getId(), "λŒ“κΈ€ λ‚΄μš©" + random.nextInt(100))); - } - } - } -} diff --git a/src/main/java/com/swyp8team2/common/domain/AuditorAwareImpl.java b/src/main/java/com/swyp8team2/common/domain/AuditorAwareImpl.java deleted file mode 100644 index 33a8add0..00000000 --- a/src/main/java/com/swyp8team2/common/domain/AuditorAwareImpl.java +++ /dev/null @@ -1,2 +0,0 @@ -package com.swyp8team2.common.domain;public class AuditorAwareImpl { -} diff --git a/src/main/java/com/swyp8team2/common/exception/ErrorCode.java b/src/main/java/com/swyp8team2/common/exception/ErrorCode.java deleted file mode 100644 index bf26eccb..00000000 --- a/src/main/java/com/swyp8team2/common/exception/ErrorCode.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.swyp8team2.common.exception; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum ErrorCode { - //400 - USER_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μœ μ €μž…λ‹ˆλ‹€."), - INVALID_ARGUMENT("잘λͺ»λœ νŒŒλΌλ―Έν„° μš”μ²­μž…λ‹ˆλ‹€."), - REFRESH_TOKEN_MISMATCHED("λ¦¬ν”„λ ˆμ‹œ 토큰이 λΆˆμΌμΉ˜ν•©λ‹ˆλ‹€."), - REFRESH_TOKEN_NOT_FOUND("λ¦¬ν”„λ ˆμ‹œ 토큰을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), - INVALID_REFRESH_TOKEN_HEADER("잘λͺ»λœ λ¦¬ν”„λ ˆμ‹œ 토큰 ν—€λ”μž…λ‹ˆλ‹€."), - MISSING_FILE_EXTENSION("ν™•μž₯μžκ°€ λˆ„λ½λμŠ΅λ‹ˆλ‹€."), - UNSUPPORTED_FILE_EXTENSION("μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν™•μž₯μžμž…λ‹ˆλ‹€."), - EXCEED_MAX_FILE_SIZE("파일 크기가 μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€."), - POST_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” κ²Œμ‹œκΈ€μž…λ‹ˆλ‹€."), - DESCRIPTION_LENGTH_EXCEEDED("κ²Œμ‹œκΈ€ μ„€λͺ… 길이가 μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€."), - INVALID_POST_IMAGE_COUNT("κ²Œμ‹œκΈ€ 이미지 κ°œμˆ˜κ°€ λ²”μœ„λ₯Ό λ²—μ–΄λ‚¬μŠ΅λ‹ˆλ‹€."), - NOT_POST_AUTHOR("κ²Œμ‹œκΈ€ μž‘μ„±μžκ°€ μ•„λ‹™λ‹ˆλ‹€."), - POST_ALREADY_CLOSED("이미 마감된 κ²Œμ‹œκΈ€μž…λ‹ˆλ‹€."), - FILE_NAME_TOO_LONG("파일 이름이 λ„ˆλ¬΄ κΉλ‹ˆλ‹€."), - ACCESS_DENIED_VOTE_STATUS("νˆ¬ν‘œ ν˜„ν™© 쑰회 κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."), - COMMENT_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λŒ“κΈ€μž…λ‹ˆλ‹€."), - VOTE_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” νˆ¬ν‘œμž…λ‹ˆλ‹€."), - NOT_VOTER("νˆ¬ν‘œμžκ°€ μ•„λ‹™λ‹ˆλ‹€."), - - //401 - EXPIRED_TOKEN("토큰이 λ§Œλ£ŒλμŠ΅λ‹ˆλ‹€."), - INVALID_TOKEN("μœ νš¨ν•˜μ§€ μ•Šμ€ ν† ν°μž…λ‹ˆλ‹€."), - INVALID_AUTH_HEADER("잘λͺ»λœ 인증 ν—€λ”μž…λ‹ˆλ‹€."), - - //403 - FORBIDDEN("κΆŒν•œ μ—†μŒ"), - - //404 - NOT_FOUND("λ¦¬μ†ŒμŠ€λ₯Ό 찾을 수 μ—†μŒ"), - - //500 - INTERNAL_SERVER_ERROR("μ„œλ²„ λ‚΄λΆ€ 였λ₯˜ λ°œμƒ"), - INVALID_INPUT_VALUE("잘λͺ»λœ μž…λ ₯ κ°’μž…λ‹ˆλ‹€."), - SOCIAL_AUTHENTICATION_FAILED("μ†Œμ…œ 둜그인이 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), - POST_IMAGE_NAME_GENERATOR_INDEX_OUT_OF_BOUND("이미지 이름 생성기 인덱슀 초과"), - IMAGE_FILE_NOT_FOUND("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ΄λ―Έμ§€μž…λ‹ˆλ‹€."), - POST_IMAGE_NOT_FOUND("κ²Œμ‹œκΈ€ 이미지가 μ—†μŠ΅λ‹ˆλ‹€."), - SHARE_URL_ALREADY_EXISTS("곡유 URL이 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€."), - - //503 - SERVICE_UNAVAILABLE("μ„œλΉ„μŠ€ 이용 λΆˆκ°€"), - ; - - private final String message; -} diff --git a/src/main/java/com/swyp8team2/common/exception/ErrorResponse.java b/src/main/java/com/swyp8team2/common/exception/ErrorResponse.java deleted file mode 100644 index d4d166c7..00000000 --- a/src/main/java/com/swyp8team2/common/exception/ErrorResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.swyp8team2.common.exception; - -public record ErrorResponse(ErrorCode errorCode) { -} diff --git a/src/main/java/com/swyp8team2/image/application/ImageService.java b/src/main/java/com/swyp8team2/image/application/ImageService.java deleted file mode 100644 index b5468f29..00000000 --- a/src/main/java/com/swyp8team2/image/application/ImageService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.swyp8team2.image.application; - -import com.swyp8team2.image.domain.ImageFile; -import com.swyp8team2.image.domain.ImageFileRepository; -import com.swyp8team2.image.presentation.dto.ImageFileDto; -import com.swyp8team2.image.presentation.dto.ImageFileResponse; -import com.swyp8team2.image.util.FileValidator; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import java.util.List; -import java.util.stream.Collectors; - -@Service -@Slf4j -@RequiredArgsConstructor -public class ImageService { - - private final R2Storage r2Storage; - private final FileValidator fileValidator; - private final ImageFileRepository imageFileRepository; - - public ImageFileResponse uploadImageFile(MultipartFile... files) { - fileValidator.validate(files); - List imageFiles = r2Storage.uploadImageFile(files); - List imageFileIds = imageFiles.stream() - .map(this::createImageFile) - .collect(Collectors.toList()); - return new ImageFileResponse(imageFileIds); - } - - public Long createImageFile(ImageFileDto imageFiledto) { - ImageFile imageFile = imageFileRepository.save(ImageFile.create(imageFiledto)); - return imageFile.getId(); - } -} diff --git a/src/main/java/com/swyp8team2/image/application/R2Storage.java b/src/main/java/com/swyp8team2/image/application/R2Storage.java deleted file mode 100644 index df824f99..00000000 --- a/src/main/java/com/swyp8team2/image/application/R2Storage.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.swyp8team2.image.application; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; -import com.swyp8team2.common.exception.ServiceUnavailableException; -import com.swyp8team2.common.util.DateTime; -import com.swyp8team2.image.presentation.dto.ImageFileDto; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.lambda.LambdaClient; -import software.amazon.awssdk.services.lambda.model.InvokeRequest; -import software.amazon.awssdk.services.lambda.model.InvokeResponse; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; - -import javax.imageio.ImageIO; -import javax.imageio.IIOImage; -import javax.imageio.IIOException; -import javax.imageio.ImageWriteParam; -import javax.imageio.ImageWriter; -import javax.imageio.stream.FileImageOutputStream; -import java.awt.Graphics2D; -import java.awt.Color; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Base64; - -@Component -@Slf4j -@RequiredArgsConstructor -public class R2Storage { - - private static final String CONVERT_EXTENSION = ".jpeg"; - - @Value("${file.endpoint}") - private String imageDomainUrl; - - @Value("${r2.bucket.name}") - private String bucketName; - - @Value("${r2.bucket.path}") - private String filePath; - - @Value("${aws.lambda-arn}") - private String lambdaFunctionName; - - private final S3Client s3Client; - private final LambdaClient lambdaClient; - - public List uploadImageFile(MultipartFile... files) { - List imageFiles = new ArrayList<>(); - try { - for (int i = 0; i < files.length; i++) { - MultipartFile file = files[i]; - String originFilename = file.getOriginalFilename(); - String fileExtension = originFilename.substring(originFilename.lastIndexOf(".")); - Map metadata = new HashMap<>(); - String realFileName = getRealFileName(filePath, i, CONVERT_EXTENSION); - - File tempFile = File.createTempFile("upload_", originFilename); - file.transferTo(tempFile); - switch(fileExtension) { - case ".heic", ".heif" -> { - convertHeicToJpg(tempFile, originFilename, realFileName); - } case ".png", ".gif" -> { - metadata.put("Content-Type", "image/jpeg"); - s3PutObject(convertToJpg(tempFile), realFileName, metadata); - } default -> { - realFileName = getRealFileName(filePath, i, fileExtension); - s3PutObject(tempFile, realFileName, metadata); - } - } - - String imageUrl = imageDomainUrl + realFileName; - imageFiles.add(new ImageFileDto(originFilename, imageUrl, imageUrl)); - deleteTempFile(tempFile); - } - - return imageFiles; - } catch (IOException e) { - log.error("Failed to upload file", e); - throw new ServiceUnavailableException(ErrorCode.SERVICE_UNAVAILABLE); - } - } - - private void convertHeicToJpg(File sourceFile, String originFilename, String realFileName) throws IOException { - byte[] fileContent = Files.readAllBytes(sourceFile.toPath()); - String base64Content = Base64.getEncoder().encodeToString(fileContent); - - Map payload = new HashMap<>(); - payload.put("fileContent", base64Content); - payload.put("originFilename", originFilename); - payload.put("key", realFileName); - - ObjectMapper objectMapper = new ObjectMapper(); - String payloadJson = objectMapper.writeValueAsString(payload); - InvokeRequest invokeRequest = InvokeRequest.builder() - .functionName(lambdaFunctionName) - .payload(SdkBytes.fromUtf8String(payloadJson)) - .build(); - - InvokeResponse response = lambdaClient.invoke(invokeRequest); - String responseJson = response.payload().asUtf8String(); - Map responseMap = objectMapper.readValue(responseJson, Map.class); - - if (responseMap.containsKey("errorMessage")) { - log.error("Lambda service error, {}", responseMap.get("errorMessage")); - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); - } - } - - private File convertToJpg(File sourceFile) throws IOException { - BufferedImage image = ImageIO.read(sourceFile); - File jpgFile = File.createTempFile("converted_", ".jpeg"); - - ImageWriter writer = ImageIO.getImageWritersByFormatName("jpeg").next(); - try (FileImageOutputStream output = new FileImageOutputStream(jpgFile)) { - writer.setOutput(output); - ImageWriteParam param = writer.getDefaultWriteParam(); - param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); - param.setCompressionQuality(0.9f); - - writer.write(null, new IIOImage(image, null, null), param); - writer.dispose(); - return jpgFile; - } catch (IIOException e) { - log.error("Failed to convert image to jpg", e); - - // μ•ŒνŒŒ 채널 처리λ₯Ό μœ„ν•΄ μƒˆ RGB 이미지 생성 (μ•ŒνŒŒ 채널 제거) - BufferedImage rgbImage = new BufferedImage( - image.getWidth(), - image.getHeight(), - BufferedImage.TYPE_INT_RGB - ); - - // 원본 이미지λ₯Ό μƒˆ RGB 이미지에 κ·Έλ¦Ό - // 흰색 λ°°κ²½ μ„€μ • - Graphics2D graphics = rgbImage.createGraphics(); - graphics.setColor(Color.WHITE); - graphics.fillRect(0, 0, rgbImage.getWidth(), rgbImage.getHeight()); - graphics.drawImage(image, 0, 0, null); - graphics.dispose(); - - try { - ImageIO.write(rgbImage, "jpeg", jpgFile); - return jpgFile; - } catch (IOException io) { - log.error("Error in JPG conversion: {}", io.getMessage()); - throw io; - } - } - } - - private void s3PutObject(File file, String realFileName, Map metadata) { - PutObjectRequest objectRequest = PutObjectRequest.builder() - .bucket(bucketName) - .metadata(metadata) - .key(realFileName) - .build(); - - s3Client.putObject(objectRequest, RequestBody.fromFile(file)); - } - - private String getRealFileName(String filePath, int sequence, String extension) { - return filePath + DateTime.getCurrentTimestamp() + sequence + extension; - } - - private void deleteTempFile(File tempFile) { - if (!tempFile.delete()) { - log.error("Failed to delete temp file: {}", tempFile.getName()); - } - } -} diff --git a/src/main/java/com/swyp8team2/image/config/S3Config.java b/src/main/java/com/swyp8team2/image/config/S3Config.java deleted file mode 100644 index 91dee5e2..00000000 --- a/src/main/java/com/swyp8team2/image/config/S3Config.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.swyp8team2.image.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; -import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.lambda.LambdaClient; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.S3Configuration; - -import java.net.URI; - -@Configuration -public class S3Config { - - @Value("${r2.access-key}") - private String r2AccessKey; - - @Value("${r2.secret-key}") - private String r2SecretKey; - - @Value("${r2.endpoint}") - private String endpoint; - - @Value("${aws.access-key}") - private String awsAccessKey; - - @Value("${aws.secret-key}") - private String awsSecretKey; - - @Bean - public S3Client s3Client() { - return S3Client.builder() - .region(Region.of("auto")) - .endpointOverride(URI.create(endpoint)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(r2AccessKey, r2SecretKey))) - .serviceConfiguration(S3Configuration.builder() - .pathStyleAccessEnabled(true) - .build()) - .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) - .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) - .build(); - } - @Bean - public LambdaClient lambdaClient() { - return LambdaClient.builder() - .region(Region.AP_NORTHEAST_2) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(awsAccessKey, awsSecretKey))) - .build(); - } -} diff --git a/src/main/java/com/swyp8team2/image/domain/ImageFile.java b/src/main/java/com/swyp8team2/image/domain/ImageFile.java deleted file mode 100644 index 12ce861c..00000000 --- a/src/main/java/com/swyp8team2/image/domain/ImageFile.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.swyp8team2.image.domain; - -import com.swyp8team2.common.domain.BaseEntity; -import com.swyp8team2.image.presentation.dto.ImageFileDto; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@Table(name = "image_files") -@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) -public class ImageFile extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, length = 100) - private String originImageName; - - @Column(nullable = false, length = 200) - private String imageUrl; - - @Column(nullable = false, length = 200) - private String thumbnailUrl; - - private ImageFile(String originImageName, String imageUrl, String thumbnailUrl) { - this.originImageName = originImageName; - this.imageUrl = imageUrl; - this.thumbnailUrl = thumbnailUrl; - } - - public static ImageFile create(ImageFileDto dto) { - return new ImageFile(dto.originFileName(), dto.imageUrl(), dto.thumbnailUrl()); - } -} diff --git a/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java b/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java deleted file mode 100644 index f277f9d5..00000000 --- a/src/main/java/com/swyp8team2/image/domain/ImageFileRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.swyp8team2.image.domain; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface ImageFileRepository extends JpaRepository { - List findByIdIn(List bestPickedImageIds); -} diff --git a/src/main/java/com/swyp8team2/image/presentation/ImageController.java b/src/main/java/com/swyp8team2/image/presentation/ImageController.java deleted file mode 100644 index 6c1912d0..00000000 --- a/src/main/java/com/swyp8team2/image/presentation/ImageController.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.swyp8team2.image.presentation; - -import com.swyp8team2.image.application.ImageService; -import com.swyp8team2.image.presentation.dto.ImageFileResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/image") -public class ImageController { - - private final ImageService r2Service; - - @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity createImageFile(@RequestPart("files") MultipartFile... files) { - ImageFileResponse response = r2Service.uploadImageFile(files); - return ResponseEntity.ok(response); - } - -} diff --git a/src/main/java/com/swyp8team2/image/util/FileValidator.java b/src/main/java/com/swyp8team2/image/util/FileValidator.java deleted file mode 100644 index 850ca2bc..00000000 --- a/src/main/java/com/swyp8team2/image/util/FileValidator.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.swyp8team2.image.util; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -import java.util.Arrays; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -@Component -public class FileValidator { - - private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - - private final Set allowedExtensions; - - public FileValidator(@Value("${file.allowed-extensions}") String allowedExtensionsConfig) { - this.allowedExtensions = Arrays.stream(allowedExtensionsConfig.split(",")) - .map(String::trim) - .map(String::toLowerCase) - .collect(Collectors.toSet()); - } - - public void validate(MultipartFile... files) { - Arrays.stream(files) - .forEach(this::validate); - } - - private void validate(MultipartFile file) { - if (file.getSize() > MAX_FILE_SIZE) { - throw new BadRequestException(ErrorCode.EXCEED_MAX_FILE_SIZE); - } - - String originalFilename = file.getOriginalFilename(); - if (originalFilename.length() > 100) { - throw new BadRequestException(ErrorCode.FILE_NAME_TOO_LONG); - } - - String ext = Optional.of(originalFilename) - .filter(name -> name.contains(".")) - .map(name -> name.substring(name.lastIndexOf('.') + 1)) - .orElseThrow(() -> new BadRequestException(ErrorCode.MISSING_FILE_EXTENSION)) - .toLowerCase(); - - if (!allowedExtensions.contains(ext)) { - throw new BadRequestException(ErrorCode.UNSUPPORTED_FILE_EXTENSION); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/swyp8team2/post/application/PostCommandService.java b/src/main/java/com/swyp8team2/post/application/PostCommandService.java deleted file mode 100644 index e56bd5c3..00000000 --- a/src/main/java/com/swyp8team2/post/application/PostCommandService.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.swyp8team2.post.application; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.post.domain.Post; -import com.swyp8team2.post.domain.PostImage; -import com.swyp8team2.post.domain.PostRepository; -import com.swyp8team2.post.presentation.dto.CreatePostRequest; -import com.swyp8team2.post.presentation.dto.CreatePostResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@Transactional -@RequiredArgsConstructor -public class PostCommandService { - - private final PostRepository postRepository; - private final ShareUrlService shareUrlShareUrlService; - - public CreatePostResponse create(Long userId, CreatePostRequest request) { - List postImages = createPostImages(request); - Post post = Post.create(userId, request.description(), postImages, request.scope(), request.voteType()); - Post save = postRepository.save(post); - save.setShareUrl(shareUrlShareUrlService.encrypt(String.valueOf(save.getId()))); - return new CreatePostResponse(save.getId(), save.getShareUrl()); - } - - private List createPostImages(CreatePostRequest request) { - PostImageNameGenerator nameGenerator = new PostImageNameGenerator(); - return request.images().stream() - .map(voteRequestDto -> PostImage.create( - nameGenerator.generate(), - voteRequestDto.imageFileId() - )).toList(); - } - - @Transactional - public void delete(Long userId, Long postId) { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); - if (!post.isAuthor(userId)) { - throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); - } - postRepository.delete(post); - } - - @Transactional - public void close(Long userId, Long postId) { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); - post.close(userId); - } - - @Transactional - public void toggleScope(Long userId, Long postId) { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); - post.toggleScope(userId); - } -} diff --git a/src/main/java/com/swyp8team2/post/application/PostQueryService.java b/src/main/java/com/swyp8team2/post/application/PostQueryService.java deleted file mode 100644 index 19eb7e4d..00000000 --- a/src/main/java/com/swyp8team2/post/application/PostQueryService.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.swyp8team2.post.application; - -import com.swyp8team2.common.dto.CursorBasePaginatedResponse; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; -import com.swyp8team2.image.domain.ImageFile; -import com.swyp8team2.image.domain.ImageFileRepository; -import com.swyp8team2.post.domain.Post; -import com.swyp8team2.post.domain.PostImage; -import com.swyp8team2.post.domain.PostImageRepository; -import com.swyp8team2.post.domain.PostRepository; -import com.swyp8team2.post.presentation.dto.FeedResponse; -import com.swyp8team2.post.presentation.dto.PostImageResponse; -import com.swyp8team2.post.presentation.dto.PostResponse; -import com.swyp8team2.post.presentation.dto.SimplePostResponse; -import com.swyp8team2.post.presentation.dto.AuthorDto; -import com.swyp8team2.post.presentation.dto.FeedDto; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import com.swyp8team2.vote.domain.Vote; -import com.swyp8team2.vote.domain.VoteRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class PostQueryService { - - private final PostRepository postRepository; - private final PostImageRepository postImageRepository; - private final UserRepository userRepository; - private final ImageFileRepository imageFileRepository; - private final VoteRepository voteRepository; - private final ShareUrlService shareUrlShareUrlService; - - public PostResponse findByShareUrl(Long userId, String shareUrl) { - String decrypt = shareUrlShareUrlService.decrypt(shareUrl); - return findById(userId, Long.valueOf(decrypt)); - } - - public PostResponse findById(Long userId, Long postId) { - Post post = postRepository.findByIdFetchPostImage(postId) - .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); - User author = userRepository.findById(post.getUserId()) - .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); - List votes = createPostImageResponse(userId, post); - boolean isAuthor = post.getUserId().equals(userId); - return PostResponse.of(post, author, votes, isAuthor); - } - - private List createPostImageResponse(Long userId, Post post) { - List images = post.getImages(); - return images.stream() - .map(image -> createVoteResponseDto(image, userId)) - .toList(); - } - - private PostImageResponse createVoteResponseDto(PostImage image, Long userId) { - ImageFile imageFile = imageFileRepository.findById(image.getImageFileId()) - .orElseThrow(() -> new InternalServerException(ErrorCode.IMAGE_FILE_NOT_FOUND)); - return new PostImageResponse( - image.getId(), - image.getName(), - imageFile.getImageUrl(), - imageFile.getThumbnailUrl(), - getVoteId(image, userId) - ); - } - - private Long getVoteId(PostImage image, Long userId) { - return voteRepository.findByUserIdAndPostImageId(userId, image.getId()) - .map(Vote::getId) - .orElse(null); - } - - public CursorBasePaginatedResponse findUserPosts(Long userId, Long cursor, int size) { - Slice postSlice = postRepository.findByUserId(userId, cursor, PageRequest.ofSize(size)); - return getCursorPaginatedResponse(postSlice); - } - - public CursorBasePaginatedResponse findVotedPosts(Long userId, Long cursor, int size) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); - List votedPostIds = voteRepository.findByUserId(user.getId()) - .map(Vote::getPostId) - .toList(); - Slice votedPostSlice = postRepository.findByIdIn(votedPostIds, cursor, PageRequest.ofSize(size)); - - return getCursorPaginatedResponse(votedPostSlice); - } - - private CursorBasePaginatedResponse getCursorPaginatedResponse(Slice postSlice) { - List bestPickedImageIds = postSlice.getContent().stream() - .map(Post::getBestPickedImage) - .map(PostImage::getImageFileId) - .toList(); - List imageIds = imageFileRepository.findByIdIn(bestPickedImageIds); - - List responseContent = postSlice.getContent().stream() - .map(post -> getSimplePostResponse(post, imageIds)) - .toList(); - - return CursorBasePaginatedResponse.of(new SliceImpl<>( - responseContent, - postSlice.getPageable(), - postSlice.hasNext() - )); - } - - private SimplePostResponse getSimplePostResponse(Post post, List imageIds) { - ImageFile bestPickedImage = imageIds.stream() - .filter(imageFile -> imageFile.getId().equals(post.getBestPickedImage().getImageFileId())) - .findFirst() - .orElseThrow(() -> new InternalServerException(ErrorCode.IMAGE_FILE_NOT_FOUND)); - return SimplePostResponse.of(post, bestPickedImage.getThumbnailUrl()); - } - - public CursorBasePaginatedResponse findFeed(Long userId, Long cursor, int size) { - Slice postSlice = postRepository.findFeedByScopeWithUser(userId, cursor, PageRequest.ofSize(size)); - return CursorBasePaginatedResponse.of(postSlice.map(post -> createFeedResponse(userId, post))); - } - - private FeedResponse createFeedResponse(Long userId, FeedDto dto) { - AuthorDto author = new AuthorDto(dto.postUserId(), dto.nickname(), dto.profileUrl()); - List postImages = postImageRepository.findByPostId(userId, dto.postId()); - boolean isAuthor = dto.postUserId().equals(userId); - return FeedResponse.of(dto, author, postImages, isAuthor); - } -} diff --git a/src/main/java/com/swyp8team2/post/application/PostService.java b/src/main/java/com/swyp8team2/post/application/PostService.java deleted file mode 100644 index 057a0adf..00000000 --- a/src/main/java/com/swyp8team2/post/application/PostService.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.swyp8team2.post.application; - -import com.swyp8team2.common.dto.CursorBasePaginatedResponse; -import com.swyp8team2.post.presentation.dto.CreatePostRequest; -import com.swyp8team2.post.presentation.dto.CreatePostResponse; -import com.swyp8team2.post.presentation.dto.FeedResponse; -import com.swyp8team2.post.presentation.dto.PostResponse; -import com.swyp8team2.post.presentation.dto.SimplePostResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class PostService { - - private final PostCommandService postCommandService; - private final PostQueryService postQueryService; - - @Transactional - public CreatePostResponse create(Long userId, CreatePostRequest request) { - return postCommandService.create(userId, request); - } - - @Transactional - public void delete(Long userId, Long postId) { - postCommandService.delete(userId, postId); - } - - @Transactional - public void close(Long userId, Long postId) { - postCommandService.close(userId, postId); - } - - @Transactional - public void toggleScope(Long userId, Long postId) { - postCommandService.toggleScope(userId, postId); - } - - public PostResponse findById(Long userId, Long postId) { - return postQueryService.findById(userId, postId); - } - - public CursorBasePaginatedResponse findUserPosts(Long userId, Long cursor, int size) { - return postQueryService.findUserPosts(userId, cursor, size); - } - - public CursorBasePaginatedResponse findVotedPosts(Long userId, Long cursor, int size) { - return postQueryService.findVotedPosts(userId, cursor, size); - } - - public PostResponse findByShareUrl(Long userId, String shareUrl) { - return postQueryService.findByShareUrl(userId, shareUrl); - } - - public CursorBasePaginatedResponse findFeed(Long userId, Long cursor, int size) { - return postQueryService.findFeed(userId, cursor, size); - } -} \ No newline at end of file diff --git a/src/main/java/com/swyp8team2/post/application/ShareUrlService.java b/src/main/java/com/swyp8team2/post/application/ShareUrlService.java deleted file mode 100644 index b16bb5e0..00000000 --- a/src/main/java/com/swyp8team2/post/application/ShareUrlService.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.swyp8team2.post.application; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; -import io.seruco.encoding.base62.Base62; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.crypto.encrypt.AesBytesEncryptor; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import java.nio.charset.StandardCharsets; - -@Slf4j -@Service -public class ShareUrlService { - - private final AesBytesEncryptor encryptor; - - public ShareUrlService( - @Value("${crypto.secret-key.share-url}") String shareUrlSymmetricKey, - @Value("${crypto.salt}") String salt - ) { - this.encryptor = new AesBytesEncryptor(shareUrlSymmetricKey, salt); - } - - public String encrypt(String data) { - try { - byte[] encrypt = encryptor.encrypt(data.getBytes(StandardCharsets.UTF_8)); - return new String(Base62.createInstance().encode(encrypt), StandardCharsets.UTF_8); - } catch (Exception e) { - log.debug("encrypt error {}", e.getMessage()); - throw new BadRequestException(ErrorCode.INVALID_TOKEN); - } - } - - public String decrypt(String encryptedData) { - try { - if (!StringUtils.hasText(encryptedData)) { - throw new InternalServerException(ErrorCode.INVALID_TOKEN); - } - byte[] decryptBytes = Base62.createInstance().decode(encryptedData.getBytes(StandardCharsets.UTF_8)); - byte[] decrypt = encryptor.decrypt(decryptBytes); - return new String(decrypt, StandardCharsets.UTF_8); - } catch (Exception e) { - log.debug("decrypt error {}", e.getMessage()); - throw new BadRequestException(ErrorCode.INVALID_TOKEN); - } - } -} diff --git a/src/main/java/com/swyp8team2/post/domain/Post.java b/src/main/java/com/swyp8team2/post/domain/Post.java deleted file mode 100644 index 74bc69f8..00000000 --- a/src/main/java/com/swyp8team2/post/domain/Post.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.swyp8team2.post.domain; - -import com.swyp8team2.common.domain.BaseEntity; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; - -@Getter -@Entity -@ToString(exclude = "images") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Post extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String description; - - private Long userId; - - @Enumerated(EnumType.STRING) - private Status status; - - @Enumerated(EnumType.STRING) - private Scope scope; - - @OneToMany(mappedBy = "post", orphanRemoval = true, cascade = CascadeType.ALL) - private List images = new ArrayList<>(); - - private String shareUrl; - - private VoteType voteType; - - public Post( - Long id, - Long userId, - String description, - Status status, - Scope scope, - List images, - String shareUrl, - VoteType voteType - ) { - validateDescription(description); - validatePostImages(images); - this.id = id; - this.description = description; - this.userId = userId; - this.status = status; - this.scope = scope; - this.images = images; - images.forEach(image -> image.setPost(this)); - this.shareUrl = shareUrl; - this.voteType = voteType; - } - - private void validatePostImages(List images) { - if (images.size() < 2 || images.size() > 9) { - throw new BadRequestException(ErrorCode.INVALID_POST_IMAGE_COUNT); - } - } - - private void validateDescription(String description) { - if (description.length() > 100) { - throw new BadRequestException(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED); - } - } - - public static Post create(Long userId, String description, List images, Scope scope, VoteType voteType) { - return new Post(null, userId, description, Status.PROGRESS, scope, images, null, voteType); - } - - public PostImage getBestPickedImage() { - return images.stream() - .max(Comparator.comparing(PostImage::getVoteCount)) - .orElseThrow(() -> new InternalServerException(ErrorCode.POST_IMAGE_NOT_FOUND)); - } - - public void vote(Long imageId) { - PostImage image = images.stream() - .filter(postImage -> postImage.getId().equals(imageId)) - .findFirst() - .orElseThrow(() -> new BadRequestException(ErrorCode.POST_IMAGE_NOT_FOUND)); - image.increaseVoteCount(); - } - - public void cancelVote(Long imageId) { - PostImage image = images.stream() - .filter(postImage -> postImage.getId().equals(imageId)) - .findFirst() - .orElseThrow(() -> new InternalServerException(ErrorCode.POST_IMAGE_NOT_FOUND)); - image.decreaseVoteCount(); - } - - public void close(Long userId) { - if (!isAuthor(userId)) { - throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); - } - if (status == Status.CLOSED) { - throw new BadRequestException(ErrorCode.POST_ALREADY_CLOSED); - } - this.status = Status.CLOSED; - } - - public boolean isAuthor(Long userId) { - return this.userId.equals(userId); - } - - public void validateProgress() { - if (!this.status.equals(Status.PROGRESS)) { - throw new BadRequestException(ErrorCode.POST_ALREADY_CLOSED); - } - } - - public void setShareUrl(String shareUrl) { - if (Objects.nonNull(this.shareUrl)) { - throw new InternalServerException(ErrorCode.SHARE_URL_ALREADY_EXISTS); - } - this.shareUrl = shareUrl; - } - - public void toggleScope(Long userId) { - if (!isAuthor(userId)) { - throw new BadRequestException(ErrorCode.NOT_POST_AUTHOR); - } - this.scope = scope.equals(Scope.PRIVATE) ? Scope.PUBLIC : Scope.PRIVATE; - } -} diff --git a/src/main/java/com/swyp8team2/post/domain/PostImage.java b/src/main/java/com/swyp8team2/post/domain/PostImage.java deleted file mode 100644 index e24a760c..00000000 --- a/src/main/java/com/swyp8team2/post/domain/PostImage.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.swyp8team2.post.domain; - -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import static com.swyp8team2.common.util.Validator.validateEmptyString; -import static com.swyp8team2.common.util.Validator.validateNull; - -@Getter -@Entity -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PostImage { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - private Post post; - - private String name; - - private Long imageFileId; - - private int voteCount; - - public PostImage(Long id, Post post, String name, Long imageFileId, int voteCount) { - this.id = id; - this.post = post; - this.name = name; - this.imageFileId = imageFileId; - this.voteCount = voteCount; - } - - public PostImage(String name, Long imageFileId, int voteCount) { - this.name = name; - this.imageFileId = imageFileId; - this.voteCount = voteCount; - } - - public static PostImage create(String name, Long imageFileId) { - return new PostImage(name, imageFileId, 0); - } - - public void setPost(Post post) { - validateNull(post); - this.post = post; - } - - public void increaseVoteCount() { - this.voteCount++; - } - - public void decreaseVoteCount() { - this.voteCount = this.voteCount == 0 ? 0 : this.voteCount - 1; - } -} diff --git a/src/main/java/com/swyp8team2/post/domain/PostImageRepository.java b/src/main/java/com/swyp8team2/post/domain/PostImageRepository.java deleted file mode 100644 index c580bb05..00000000 --- a/src/main/java/com/swyp8team2/post/domain/PostImageRepository.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.swyp8team2.post.domain; - -import com.swyp8team2.post.presentation.dto.PostImageResponse; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface PostImageRepository extends JpaRepository { - - @Query(""" - SELECT new com.swyp8team2.post.presentation.dto.PostImageResponse( - pi.id, - pi.name, - i.imageUrl, - i.thumbnailUrl, - (SELECT v.id FROM Vote v WHERE v.postImageId = pi.id AND v.userId = :userId) - ) - FROM PostImage pi - INNER JOIN ImageFile i ON pi.imageFileId = i.id - WHERE pi.post.id = :postId - ORDER BY pi.id ASC - """ - ) - List findByPostId(@Param("userId") Long userId, @Param("postId") Long postId); -} diff --git a/src/main/java/com/swyp8team2/post/domain/PostRepository.java b/src/main/java/com/swyp8team2/post/domain/PostRepository.java deleted file mode 100644 index 3a9e6030..00000000 --- a/src/main/java/com/swyp8team2/post/domain/PostRepository.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.swyp8team2.post.domain; - -import com.swyp8team2.post.presentation.dto.FeedDto; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface PostRepository extends JpaRepository { - - @Query(""" - SELECT p - FROM Post p - WHERE p.userId = :userId - AND (:postId IS NULL OR p.id < :postId) - ORDER BY p.id DESC - """ - ) - Slice findByUserId(@Param("userId") Long userId, @Param("postId") Long postId, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.id IN :postIds - AND (:postId IS NULL OR p.id < :postId) - ORDER BY p.id DESC - """ - ) - Slice findByIdIn(@Param("postIds") List postIds, @Param("postId") Long postId, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - JOIN FETCH p.images - WHERE p.id = :postId - """ - ) - Optional findByIdFetchPostImage(@Param("postId") Long postId); - - @Query(""" - SELECT new com.swyp8team2.post.presentation.dto.FeedDto( - p.id, - p.status , - p.description , - p.shareUrl , - p.userId , - u.nickname, - u.profileUrl, - cast((select count(distinct v.userId) from Vote v where p.id = v.postId) as long), - cast((select count(*) from Comment c where p.id = c.postId and c.deleted = false) as long) - ) - FROM Post p - INNER JOIN User u on p.userId = u.id - WHERE p.deleted = false - AND p.scope = 'PUBLIC' - AND (:postId IS NULL OR p.id < :postId) - ORDER BY p.createdAt DESC - """ - ) - Slice findFeedByScopeWithUser(@Param("userId") Long userId, @Param("postId") Long postId, Pageable pageable); -} diff --git a/src/main/java/com/swyp8team2/post/domain/VoteType.java b/src/main/java/com/swyp8team2/post/domain/VoteType.java deleted file mode 100644 index 43ae8f03..00000000 --- a/src/main/java/com/swyp8team2/post/domain/VoteType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.swyp8team2.post.domain; - -public enum VoteType { - SINGLE, MULTIPLE -} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java b/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java deleted file mode 100644 index 5697ae28..00000000 --- a/src/main/java/com/swyp8team2/post/presentation/dto/CreatePostRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.swyp8team2.post.presentation.dto; - -import com.swyp8team2.post.domain.Scope; -import com.swyp8team2.post.domain.VoteType; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; - -import java.util.List; - -public record CreatePostRequest( - @NotNull - String description, - - @Valid @NotNull - List images, - - @NotNull - Scope scope, - - @NotNull - VoteType voteType -) { -} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/FeedDto.java b/src/main/java/com/swyp8team2/post/presentation/dto/FeedDto.java deleted file mode 100644 index 276f87f3..00000000 --- a/src/main/java/com/swyp8team2/post/presentation/dto/FeedDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.swyp8team2.post.presentation.dto; - -import com.swyp8team2.post.domain.Status; - -public record FeedDto( - Long postId, - Status status, - String description, - String shareUrl, - Long postUserId, - String nickname, - String profileUrl, - Long participantCount, - Long commentCount) { -} \ No newline at end of file diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/FeedResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/FeedResponse.java deleted file mode 100644 index 45212cdb..00000000 --- a/src/main/java/com/swyp8team2/post/presentation/dto/FeedResponse.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.swyp8team2.post.presentation.dto; - -import com.swyp8team2.common.dto.CursorDto; -import com.swyp8team2.post.domain.Status; - -import java.util.List; - -public record FeedResponse( - Long id, - AuthorDto author, - List images, - Status status, - String description, - String shareUrl, - boolean isAuthor, - Long participantCount, - Long commentCount - -) implements CursorDto { - - public static FeedResponse of(FeedDto feedDto, AuthorDto author, List images, boolean isAuthor) { - return new FeedResponse( - feedDto.postId(), - author, - images, - feedDto.status(), - feedDto.description(), - feedDto.shareUrl(), - isAuthor, - feedDto.participantCount(), - feedDto.commentCount() - ); - } - - @Override - public long getId() { - return id; - } -} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageRequestDto.java b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageRequestDto.java deleted file mode 100644 index 15aeeeef..00000000 --- a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageRequestDto.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.swyp8team2.post.presentation.dto; - -import jakarta.validation.constraints.NotNull; - -public record PostImageRequestDto( - @NotNull - Long imageFileId -) { -} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java deleted file mode 100644 index 7eff9968..00000000 --- a/src/main/java/com/swyp8team2/post/presentation/dto/PostImageResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.swyp8team2.post.presentation.dto; - -public record PostImageResponse( - Long id, - String imageName, - String imageUrl, - String thumbnailUrl, - Long voteId -) { -} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/PostResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/PostResponse.java deleted file mode 100644 index b41421eb..00000000 --- a/src/main/java/com/swyp8team2/post/presentation/dto/PostResponse.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.swyp8team2.post.presentation.dto; - -import com.swyp8team2.post.domain.Post; -import com.swyp8team2.post.domain.Status; -import com.swyp8team2.user.domain.User; - -import java.time.LocalDateTime; -import java.util.List; - -public record PostResponse( - Long id, - AuthorDto author, - String description, - List images, - String shareUrl, - boolean isAuthor, - Status status, - LocalDateTime createdAt -) { - public static PostResponse of(Post post, User user, List images, boolean isAuthor) { - return new PostResponse( - post.getId(), - AuthorDto.of(user), - post.getDescription(), - images, - post.getShareUrl(), - isAuthor, - post.getStatus(), - post.getCreatedAt() - ); - } -} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/SimplePostResponse.java b/src/main/java/com/swyp8team2/post/presentation/dto/SimplePostResponse.java deleted file mode 100644 index 2613a8e3..00000000 --- a/src/main/java/com/swyp8team2/post/presentation/dto/SimplePostResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.swyp8team2.post.presentation.dto; - -import com.swyp8team2.common.dto.CursorDto; -import com.swyp8team2.post.domain.Post; - -import java.time.LocalDateTime; - -public record SimplePostResponse( - long id, - String bestPickedImageUrl, - String shareUrl, - LocalDateTime createdAt -) implements CursorDto { - - public static SimplePostResponse of(Post post, String bestPickedImageUrl) { - return new SimplePostResponse( - post.getId(), - bestPickedImageUrl, - post.getShareUrl(), - post.getCreatedAt() - ); - } - - @Override - public long getId() { - return id; - } -} diff --git a/src/main/java/com/swyp8team2/post/presentation/dto/UpdatePostRequest.java b/src/main/java/com/swyp8team2/post/presentation/dto/UpdatePostRequest.java deleted file mode 100644 index bf3b9b79..00000000 --- a/src/main/java/com/swyp8team2/post/presentation/dto/UpdatePostRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.swyp8team2.post.presentation.dto; - -import jakarta.validation.constraints.NotNull; - -public record UpdatePostRequest( - @NotNull - String description -) { -} diff --git a/src/main/java/com/swyp8team2/user/application/NicknameGenerator.java b/src/main/java/com/swyp8team2/user/application/NicknameGenerator.java deleted file mode 100644 index 435df47e..00000000 --- a/src/main/java/com/swyp8team2/user/application/NicknameGenerator.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.swyp8team2.user.application; - -import com.swyp8team2.user.domain.NicknameAdjectiveRepository; -import com.swyp8team2.user.domain.Role; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class NicknameGenerator { - - private final NicknameAdjectiveRepository nicknameAdjectiveRepository; - - public String generate(Role role) { - return nicknameAdjectiveRepository.findRandomNicknameAdjective() - .map(adjective -> adjective.getAdjective() + " " + role.getNickname()) - .orElse("μˆ¨κ²¨μ§„ " + role.getNickname()); - } -} diff --git a/src/main/java/com/swyp8team2/user/application/UserService.java b/src/main/java/com/swyp8team2/user/application/UserService.java deleted file mode 100644 index e008f3f2..00000000 --- a/src/main/java/com/swyp8team2/user/application/UserService.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.swyp8team2.user.application; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.user.domain.Role; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import com.swyp8team2.user.presentation.dto.UserInfoResponse; -import com.swyp8team2.user.presentation.dto.UserMyInfoResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class UserService { - - private final UserRepository userRepository; - private final NicknameGenerator nicknameGenerator; - - @Transactional - public Long createUser(String nickname, String profileImageUrl) { - User user = userRepository.save(User.create(getNickname(nickname), getProfileImage(profileImageUrl))); - return user.getId(); - } - - private String getNickname(String nickname) { - return Optional.ofNullable(nickname) - .orElseGet(() -> nicknameGenerator.generate(Role.USER)); - } - - private String getProfileImage(String profileImageUrl) { - return Optional.ofNullable(profileImageUrl) - .orElse(User.DEFAULT_PROFILE_URL); - } - - @Transactional - public User createGuest() { - return userRepository.save(User.createGuest(nicknameGenerator.generate(Role.GUEST))); - } - - public UserInfoResponse findById(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); - return UserInfoResponse.of(user); - } - - public UserMyInfoResponse findByMe(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); - return UserMyInfoResponse.of(user); - } -} diff --git a/src/main/java/com/swyp8team2/user/domain/User.java b/src/main/java/com/swyp8team2/user/domain/User.java deleted file mode 100644 index ff9f52ed..00000000 --- a/src/main/java/com/swyp8team2/user/domain/User.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.swyp8team2.user.domain; - -import com.swyp8team2.common.domain.BaseEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table(name = "users") -@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) -public class User extends BaseEntity { - - public static final String DEFAULT_PROFILE_URL = "https://image.photopic.site/default_profile.png"; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String nickname; - - private String profileUrl; - - @Enumerated(jakarta.persistence.EnumType.STRING) - public Role role; - - public User(Long id, String nickname, String profileUrl, Role role) { - this.id = id; - this.nickname = nickname; - this.profileUrl = profileUrl; - this.role = role; - } - - public static User create(String nickname, String profileUrl) { - return new User(null, nickname, profileUrl, Role.USER); - } - - public static User createGuest(String nickname) { - return new User( - null, - nickname, - DEFAULT_PROFILE_URL, - Role.GUEST - ); - } -} diff --git a/src/main/java/com/swyp8team2/user/presentation/UserController.java b/src/main/java/com/swyp8team2/user/presentation/UserController.java deleted file mode 100644 index 3f81d259..00000000 --- a/src/main/java/com/swyp8team2/user/presentation/UserController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.swyp8team2.user.presentation; - -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.user.application.UserService; -import com.swyp8team2.user.presentation.dto.UserInfoResponse; -import com.swyp8team2.user.presentation.dto.UserMyInfoResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/users") -public class UserController { - - private final UserService userService; - - @GetMapping("/{userId}") - public ResponseEntity findUserInfo(@PathVariable("userId") Long userId) { - return ResponseEntity.ok(userService.findById(userId)); - } - - @GetMapping("/me") - public ResponseEntity findMyInfo( - @AuthenticationPrincipal UserInfo userInfo - ) { - return ResponseEntity.ok(userService.findByMe(userInfo.userId())); - } -} diff --git a/src/main/java/com/swyp8team2/user/presentation/dto/UserInfoResponse.java b/src/main/java/com/swyp8team2/user/presentation/dto/UserInfoResponse.java deleted file mode 100644 index 13bf6e26..00000000 --- a/src/main/java/com/swyp8team2/user/presentation/dto/UserInfoResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.swyp8team2.user.presentation.dto; - -import com.swyp8team2.user.domain.User; - -public record UserInfoResponse( - Long id, - String nickname, - String profileUrl -) { - public static UserInfoResponse of(User user) { - return new UserInfoResponse(user.getId(), user.getNickname(), user.getProfileUrl()); - } -} diff --git a/src/main/java/com/swyp8team2/user/presentation/dto/UserMyInfoResponse.java b/src/main/java/com/swyp8team2/user/presentation/dto/UserMyInfoResponse.java deleted file mode 100644 index 7aafd096..00000000 --- a/src/main/java/com/swyp8team2/user/presentation/dto/UserMyInfoResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.swyp8team2.user.presentation.dto; - -import com.swyp8team2.user.domain.Role; -import com.swyp8team2.user.domain.User; - -public record UserMyInfoResponse( - Long id, - String nickname, - String profileImageUrl, - Role role -) { - public static UserMyInfoResponse of(User user) { - return new UserMyInfoResponse(user.getId(), user.getNickname(), user.getProfileUrl(), user.getRole()); - } -} diff --git a/src/main/java/com/swyp8team2/vote/application/VoteService.java b/src/main/java/com/swyp8team2/vote/application/VoteService.java deleted file mode 100644 index d3f69e12..00000000 --- a/src/main/java/com/swyp8team2/vote/application/VoteService.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.swyp8team2.vote.application; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.post.domain.Post; -import com.swyp8team2.post.domain.PostImage; -import com.swyp8team2.post.domain.PostRepository; -import com.swyp8team2.post.domain.VoteType; -import com.swyp8team2.vote.presentation.dto.PostImageVoteStatusResponse; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import com.swyp8team2.vote.domain.Vote; -import com.swyp8team2.vote.domain.VoteRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Comparator; -import java.util.List; -import java.util.Optional; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class VoteService { - - private final VoteRepository voteRepository; - private final UserRepository userRepository; - private final PostRepository postRepository; - private final RatioCalculator ratioCalculator; - - @Transactional - public Long vote(Long voterId, Long postId, Long imageId) { - Optional existsVote = voteRepository.findByUserIdAndPostImageId(voterId, imageId); - if (existsVote.isPresent()) { - return existsVote.get().getId(); - } - Post post = postRepository.findById(postId) - .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); - post.validateProgress(); - - User voter = userRepository.findById(voterId) - .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); - - VoteType voteType = post.getVoteType(); - if (VoteType.SINGLE.equals(voteType)) { - deleteVoteIfExisting(post, voter.getId()); - } - Vote vote = createVote(post, imageId, voter.getId()); - return vote.getId(); - } - - private void deleteVoteIfExisting(Post post, Long userId) { - List votes = voteRepository.findByUserIdAndPostId(userId, post.getId()); - for (Vote vote : votes) { - voteRepository.delete(vote); - post.cancelVote(vote.getPostImageId()); - } - } - - private Vote createVote(Post post, Long imageId, Long userId) { - Vote vote = voteRepository.save(Vote.of(post.getId(), imageId, userId)); - post.vote(imageId); - return vote; - } - - @Transactional - public void cancelVote(Long userId, Long voteId) { - Vote vote = voteRepository.findById(voteId) - .orElseThrow(() -> new BadRequestException(ErrorCode.VOTE_NOT_FOUND)); - if (!vote.isVoter(userId)) { - throw new BadRequestException(ErrorCode.NOT_VOTER); - } - voteRepository.delete(vote); - Post post = postRepository.findById(vote.getPostId()) - .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); - post.cancelVote(vote.getPostImageId()); - } - - public List findVoteStatus(Long userId, Long postId) { - Post post = postRepository.findByIdFetchPostImage(postId) - .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); - validateVoteStatus(userId, post); - int totalVoteCount = getTotalVoteCount(post.getImages()); - return post.getImages().stream() - .map(image -> { - String ratio = ratioCalculator.calculate(totalVoteCount, image.getVoteCount()); - return new PostImageVoteStatusResponse(image.getId(), image.getName(), image.getVoteCount(), ratio); - }) - .sorted(Comparator.comparingInt(PostImageVoteStatusResponse::voteCount).reversed()) - .toList(); - } - - private void validateVoteStatus(Long userId, Post post) { - List votes = voteRepository.findByUserIdAndPostId(userId, post.getId()); - if (!(post.isAuthor(userId) || !votes.isEmpty())) { - throw new BadRequestException(ErrorCode.ACCESS_DENIED_VOTE_STATUS); - } - } - - private int getTotalVoteCount(List images) { - int totalVoteCount = 0; - for (PostImage image : images) { - totalVoteCount += image.getVoteCount(); - } - return totalVoteCount; - } -} diff --git a/src/main/java/com/swyp8team2/vote/domain/Vote.java b/src/main/java/com/swyp8team2/vote/domain/Vote.java deleted file mode 100644 index 30d3928a..00000000 --- a/src/main/java/com/swyp8team2/vote/domain/Vote.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.swyp8team2.vote.domain; - -import com.swyp8team2.common.domain.BaseEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table(name = "user_votes") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Vote extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private Long postId; - - private Long postImageId; - - private Long userId; - - public Vote(Long id, Long postId, Long postImageId, Long userId) { - this.id = id; - this.postId = postId; - this.postImageId = postImageId; - this.userId = userId; - } - - public static Vote of(Long postId, Long postImageId, Long userId) { - return new Vote(null, postId, postImageId, userId); - } - - public boolean isVoter(Long userId) { - return this.userId.equals(userId); - } -} diff --git a/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java b/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java deleted file mode 100644 index 53c697a7..00000000 --- a/src/main/java/com/swyp8team2/vote/domain/VoteRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.swyp8team2.vote.domain; - -import org.springframework.data.domain.Slice; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface VoteRepository extends JpaRepository { - List findByUserIdAndPostId(Long userId, Long postId); - - Slice findByUserId(Long userId); - - Optional findByUserIdAndPostImageId(Long voterId, Long imageId); - - Optional findByIdAndUserId(Long voteId, Long userId); - - List findByPostIdAndDeletedFalse(Long postId); -} diff --git a/src/main/java/com/swyp8team2/vote/presentation/dto/PostImageVoteStatusResponse.java b/src/main/java/com/swyp8team2/vote/presentation/dto/PostImageVoteStatusResponse.java deleted file mode 100644 index 86f2e4b0..00000000 --- a/src/main/java/com/swyp8team2/vote/presentation/dto/PostImageVoteStatusResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.swyp8team2.vote.presentation.dto; - -public record PostImageVoteStatusResponse( - Long id, - String imageName, - int voteCount, - String voteRatio -) { -} diff --git a/src/main/java/com/swyp8team2/vote/presentation/dto/VoteRequest.java b/src/main/java/com/swyp8team2/vote/presentation/dto/VoteRequest.java deleted file mode 100644 index 9c7b2adb..00000000 --- a/src/main/java/com/swyp8team2/vote/presentation/dto/VoteRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.swyp8team2.vote.presentation.dto; - -import jakarta.validation.constraints.NotNull; - -public record VoteRequest( - @NotNull - Long imageId -) { -} diff --git a/src/main/resources/notification/messages.properties b/src/main/resources/notification/messages.properties new file mode 100644 index 00000000..af6e6c91 --- /dev/null +++ b/src/main/resources/notification/messages.properties @@ -0,0 +1,15 @@ +# COMMENT_LIKED +NOTI.COMMENT.LIKED.title={0} λ‹˜μ΄ λŒ“κΈ€μ— μ’‹μ•„μš”λ₯Ό λˆŒλ €μ–΄μš”! +NOTI.COMMENT.LIKED.content=μ§€κΈˆ λ°”λ‘œ ν™•μΈν•΄λ³΄μ„Έμš”. + +# POST_VOTED +NOTI.POST.VOTED.title={0} λ‹˜μ΄ "{1}" νˆ¬ν‘œμ— μ°Έμ—¬ν–ˆμ–΄μš”! +NOTI.POST.VOTED.content=μ§€κΈˆ λ°”λ‘œ κ²°κ³Όλ₯Ό ν™•μΈν•΄λ³΄μ„Έμš”. + +# MY_POST_CLOSED +NOTI.MY.POST.CLOSED.title=당신이 λ§Œλ“  "{1}" νˆ¬ν‘œκ°€ λ§ˆκ°λμŠ΅λ‹ˆλ‹€! +NOTI.MY.POST.CLOSED.content=μ§€κΈˆ λ°”λ‘œ κ²°κ³Όλ₯Ό ν™•μΈν•΄λ³΄μ„Έμš”. + +# POST_CLOSED +NOTI.POST.CLOSED.title=당신이 μ°Έμ—¬ν•œ "{1}" νˆ¬ν‘œκ°€ λ§ˆκ°λμŠ΅λ‹ˆλ‹€! +NOTI.POST.CLOSED.content=μ§€κΈˆ λ°”λ‘œ κ²°κ³Όλ₯Ό ν™•μΈν•΄λ³΄μ„Έμš”. \ No newline at end of file diff --git a/src/test/compose.yaml b/src/test/compose.yaml new file mode 100644 index 00000000..51883b2f --- /dev/null +++ b/src/test/compose.yaml @@ -0,0 +1,10 @@ +services: + mysql: + image: 'mysql:latest' + environment: + - 'MYSQL_DATABASE=chooz' + - 'MYSQL_PASSWORD=secret' + - 'MYSQL_ROOT_PASSWORD=verysecret' + - 'MYSQL_USER=chooz' + ports: + - '3306:3306' diff --git a/src/test/java/com/swyp8team2/Swyp8team2ApplicationTests.java b/src/test/java/com/chooz/ChoozApplicationTests.java similarity index 73% rename from src/test/java/com/swyp8team2/Swyp8team2ApplicationTests.java rename to src/test/java/com/chooz/ChoozApplicationTests.java index 65758db2..7a40c285 100644 --- a/src/test/java/com/swyp8team2/Swyp8team2ApplicationTests.java +++ b/src/test/java/com/chooz/ChoozApplicationTests.java @@ -1,10 +1,10 @@ -package com.swyp8team2; +package com.chooz; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -class Swyp8team2ApplicationTests { +class ChoozApplicationTests { @Test void contextLoads() { diff --git a/src/test/java/com/swyp8team2/auth/application/AuthServiceTest.java b/src/test/java/com/chooz/auth/application/AuthServiceTest.java similarity index 66% rename from src/test/java/com/swyp8team2/auth/application/AuthServiceTest.java rename to src/test/java/com/chooz/auth/application/AuthServiceTest.java index f2e4959e..0bacdcf5 100644 --- a/src/test/java/com/swyp8team2/auth/application/AuthServiceTest.java +++ b/src/test/java/com/chooz/auth/application/AuthServiceTest.java @@ -1,23 +1,24 @@ -package com.swyp8team2.auth.application; +package com.chooz.auth.application; -import com.swyp8team2.auth.application.jwt.JwtProvider; -import com.swyp8team2.auth.application.jwt.TokenPair; -import com.swyp8team2.auth.application.oauth.OAuthService; -import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; -import com.swyp8team2.auth.domain.Provider; -import com.swyp8team2.auth.domain.SocialAccount; -import com.swyp8team2.auth.domain.SocialAccountRepository; -import com.swyp8team2.auth.presentation.dto.TokenResponse; -import com.swyp8team2.support.IntegrationTest; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import org.assertj.core.api.Assertions; +import com.chooz.auth.application.jwt.JwtProvider; +import com.chooz.auth.application.jwt.TokenPair; +import com.chooz.auth.application.oauth.OAuthService; +import com.chooz.auth.application.oauth.dto.OAuthUserInfo; +import com.chooz.auth.domain.Provider; +import com.chooz.auth.domain.SocialAccount; +import com.chooz.auth.domain.SocialAccountRepository; +import com.chooz.auth.presentation.dto.TokenResponse; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import static org.assertj.core.api.Assertions.*; +import java.util.Optional; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; @@ -53,12 +54,12 @@ void oAuthSignIn() throws Exception { .willReturn(expectedTokenPair); //when - TokenResponse tokenResponse = authService.oauthSignIn("code", "https://dev.photopic.site"); + TokenResponse tokenResponse = authService.oauthSignIn("code", "https://dev.chooz.site"); //then TokenPair tokenPair = tokenResponse.tokenPair(); SocialAccount socialAccount = socialAccountRepository.findBySocialIdAndProvider(oAuthUserInfo.socialId(), Provider.KAKAO).get(); - User user = userRepository.findById(socialAccount.getId()).get(); + User user = userRepository.findById(socialAccount.getUserId()).get(); assertAll( () -> assertThat(tokenPair).isEqualTo(expectedTokenPair), () -> assertThat(socialAccount.getUserId()).isNotNull(), @@ -68,4 +69,18 @@ void oAuthSignIn() throws Exception { () -> assertThat(user.getProfileUrl()).isEqualTo(oAuthUserInfo.profileImageUrl()) ); } + + @Test + @DisplayName("νšŒμ› νƒˆν‡΄ ν…ŒμŠ€νŠΈ") + void withdraw() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + + // when + authService.withdraw(user.getId()); + + // then + Optional deletedUser = userRepository.findById(user.getId()); + assertThat(deletedUser).isEmpty(); + } } diff --git a/src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java b/src/test/java/com/chooz/auth/application/JwtClaimTest.java similarity index 69% rename from src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java rename to src/test/java/com/chooz/auth/application/JwtClaimTest.java index fd0d5ffa..10ecff7a 100644 --- a/src/test/java/com/swyp8team2/auth/application/JwtClaimTest.java +++ b/src/test/java/com/chooz/auth/application/JwtClaimTest.java @@ -1,8 +1,7 @@ -package com.swyp8team2.auth.application; +package com.chooz.auth.application; -import com.swyp8team2.auth.application.jwt.JwtClaim; -import com.swyp8team2.user.domain.Role; -import org.assertj.core.api.Assertions; +import com.chooz.auth.application.jwt.JwtClaim; +import com.chooz.user.domain.Role; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,7 +14,7 @@ class JwtClaimTest { void idAsLong() { // given long givenId = 1; - Role givenRole = Role.GUEST; + Role givenRole = Role.USER; // when JwtClaim jwtClaim = JwtClaim.from(givenId, givenRole); diff --git a/src/test/java/com/chooz/auth/application/JwtProviderIntegrationTest.java b/src/test/java/com/chooz/auth/application/JwtProviderIntegrationTest.java new file mode 100644 index 00000000..8a8baed9 --- /dev/null +++ b/src/test/java/com/chooz/auth/application/JwtProviderIntegrationTest.java @@ -0,0 +1,31 @@ +package com.chooz.auth.application; + +import com.chooz.auth.application.jwt.JwtClaim; +import com.chooz.auth.application.jwt.JwtProvider; +import com.chooz.auth.application.jwt.TokenPair; +import com.chooz.support.IntegrationTest; +import com.chooz.user.domain.Role; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +public class JwtProviderIntegrationTest extends IntegrationTest { + + @Autowired + private JwtProvider jwtProvider; + + @Test + @Disabled + @DisplayName("토큰 생성") + void create() throws Exception { + //given + + + //when + TokenPair token = jwtProvider.createToken(new JwtClaim(1L, Role.USER)); + + //then + System.out.println("token.accessToken() = " + token.accessToken()); + } +} diff --git a/src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java b/src/test/java/com/chooz/auth/application/JwtProviderTest.java similarity index 90% rename from src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java rename to src/test/java/com/chooz/auth/application/JwtProviderTest.java index 0d2da3e7..cfed6141 100644 --- a/src/test/java/com/swyp8team2/auth/application/JwtProviderTest.java +++ b/src/test/java/com/chooz/auth/application/JwtProviderTest.java @@ -1,11 +1,11 @@ -package com.swyp8team2.auth.application; +package com.chooz.auth.application; -import com.swyp8team2.auth.application.jwt.JwtClaim; -import com.swyp8team2.auth.application.jwt.JwtProvider; -import com.swyp8team2.auth.application.jwt.TokenPair; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.UnauthorizedException; -import com.swyp8team2.user.domain.Role; +import com.chooz.auth.application.jwt.JwtClaim; +import com.chooz.auth.application.jwt.JwtProvider; +import com.chooz.auth.application.jwt.TokenPair; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.UnauthorizedException; +import com.chooz.user.domain.Role; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java b/src/test/java/com/chooz/auth/application/JwtServiceTest.java similarity index 75% rename from src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java rename to src/test/java/com/chooz/auth/application/JwtServiceTest.java index d33ce4ba..f4c3331f 100644 --- a/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java +++ b/src/test/java/com/chooz/auth/application/JwtServiceTest.java @@ -1,16 +1,16 @@ -package com.swyp8team2.auth.application; - -import com.swyp8team2.auth.application.jwt.JwtClaim; -import com.swyp8team2.auth.application.jwt.JwtProvider; -import com.swyp8team2.auth.application.jwt.JwtService; -import com.swyp8team2.auth.application.jwt.TokenPair; -import com.swyp8team2.auth.domain.RefreshToken; -import com.swyp8team2.auth.domain.RefreshTokenRepository; -import com.swyp8team2.auth.presentation.dto.TokenResponse; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.support.IntegrationTest; -import com.swyp8team2.user.domain.Role; +package com.chooz.auth.application; + +import com.chooz.auth.application.jwt.JwtClaim; +import com.chooz.auth.application.jwt.JwtProvider; +import com.chooz.auth.application.jwt.JwtService; +import com.chooz.auth.application.jwt.TokenPair; +import com.chooz.auth.domain.RefreshToken; +import com.chooz.auth.domain.RefreshTokenRepository; +import com.chooz.auth.presentation.dto.TokenResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.support.IntegrationTest; +import com.chooz.user.domain.Role; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -108,30 +108,15 @@ void reissue_refreshTokenMismatched() throws Exception { @Test @DisplayName("λ‘œκ·Έμ•„μ›ƒν•˜λ©΄ refresh token을 dbμ—μ„œ μ‚­μ œν•΄μ•Ό 함") - void signOut() throws Exception { + void removeToken() throws Exception { //given long givenUserId = 1L; - String givenRefreshToken = "refreshToken"; - refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); + refreshTokenRepository.save(new RefreshToken(givenUserId, "refreshToken")); //when - jwtService.signOut(givenUserId, givenRefreshToken); + jwtService.removeToken(givenUserId); //then assertThat(refreshTokenRepository.findByUserId(givenUserId)).isEmpty(); } - - @Test - @DisplayName("λ‘œκ·Έμ•„μ›ƒ - μœ μ €μ˜ refresh token이 μ•„λ‹Œ 경우") - void signOut_invalidRefreshToken() throws Exception { - //given - long givenUserId = 1L; - String givenRefreshToken = "refreshToken"; - refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); - - //when then - assertThatThrownBy(() -> jwtService.signOut(givenUserId, "differentToken")) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.REFRESH_TOKEN_MISMATCHED.getMessage()); - } } diff --git a/src/test/java/com/swyp8team2/auth/domain/RefreshTokenTest.java b/src/test/java/com/chooz/auth/domain/RefreshTokenTest.java similarity index 87% rename from src/test/java/com/swyp8team2/auth/domain/RefreshTokenTest.java rename to src/test/java/com/chooz/auth/domain/RefreshTokenTest.java index 29c985c8..edca7247 100644 --- a/src/test/java/com/swyp8team2/auth/domain/RefreshTokenTest.java +++ b/src/test/java/com/chooz/auth/domain/RefreshTokenTest.java @@ -1,7 +1,7 @@ -package com.swyp8team2.auth.domain; +package com.chooz.auth.domain; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/swyp8team2/auth/domain/SocialAccountTest.java b/src/test/java/com/chooz/auth/domain/SocialAccountTest.java similarity index 90% rename from src/test/java/com/swyp8team2/auth/domain/SocialAccountTest.java rename to src/test/java/com/chooz/auth/domain/SocialAccountTest.java index f73cd9ad..24303f2d 100644 --- a/src/test/java/com/swyp8team2/auth/domain/SocialAccountTest.java +++ b/src/test/java/com/chooz/auth/domain/SocialAccountTest.java @@ -1,8 +1,8 @@ -package com.swyp8team2.auth.domain; +package com.chooz.auth.domain; -import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.InternalServerException; +import com.chooz.auth.application.oauth.dto.OAuthUserInfo; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.InternalServerException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java b/src/test/java/com/chooz/auth/presentation/AuthControllerTest.java similarity index 72% rename from src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java rename to src/test/java/com/chooz/auth/presentation/AuthControllerTest.java index cee695b9..a0bba57d 100644 --- a/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java +++ b/src/test/java/com/chooz/auth/presentation/AuthControllerTest.java @@ -1,17 +1,17 @@ -package com.swyp8team2.auth.presentation; - -import com.swyp8team2.auth.application.AuthService; -import com.swyp8team2.auth.application.jwt.TokenPair; -import com.swyp8team2.auth.presentation.dto.OAuthSignInRequest; -import com.swyp8team2.auth.presentation.dto.AuthResponse; -import com.swyp8team2.auth.presentation.dto.TokenResponse; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.ErrorResponse; -import com.swyp8team2.common.presentation.CustomHeader; -import com.swyp8team2.support.RestDocsTest; -import com.swyp8team2.support.WithMockUserInfo; -import com.swyp8team2.user.domain.Role; +package com.chooz.auth.presentation; + +import com.chooz.auth.application.AuthService; +import com.chooz.auth.application.jwt.TokenPair; +import com.chooz.auth.presentation.dto.AuthResponse; +import com.chooz.auth.presentation.dto.OAuthSignInRequest; +import com.chooz.auth.presentation.dto.TokenResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.ErrorResponse; +import com.chooz.common.presentation.CustomHeader; +import com.chooz.support.RestDocsTest; +import com.chooz.support.WithMockUserInfo; +import com.chooz.user.domain.Role; import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,15 +20,13 @@ import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithAnonymousUser; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; 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.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; @@ -50,7 +48,7 @@ void kakaoOAuthSignIn() throws Exception { AuthResponse response = new AuthResponse(expectedTokenPair.accessToken(), 1L, Role.USER); given(authService.oauthSignIn(anyString(), anyString())) .willReturn(new TokenResponse(expectedTokenPair, 1L, Role.USER)); - OAuthSignInRequest request = new OAuthSignInRequest("code", "https://dev.photopic.site"); + OAuthSignInRequest request = new OAuthSignInRequest("code", "https://dev.chooz.site"); //when then mockMvc.perform(post("/auth/oauth2/code/kakao") @@ -80,44 +78,6 @@ void kakaoOAuthSignIn() throws Exception { )); } - @Test - @DisplayName("게슀트 둜그인") - void guestSignIn() throws Exception { - //given - TokenPair expectedTokenPair = new TokenPair("accessToken", "refreshToken"); - AuthResponse response = new AuthResponse(expectedTokenPair.accessToken(), 1L, Role.USER); - given(authService.guestSignIn(any())) - .willReturn(new TokenResponse(expectedTokenPair, 1L, Role.USER)); - - //when then - mockMvc.perform(post("/auth/guest/sign-in") - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, "refreshToken"))) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andExpect(cookie().value(CustomHeader.CustomCookie.REFRESH_TOKEN, expectedTokenPair.refreshToken())) - .andExpect(cookie().httpOnly(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) - .andExpect(cookie().path(CustomHeader.CustomCookie.REFRESH_TOKEN, "/")) - .andExpect(cookie().secure(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) - .andExpect(cookie().attribute(CustomHeader.CustomCookie.REFRESH_TOKEN, "SameSite", "None")) - .andExpect(cookie().maxAge(CustomHeader.CustomCookie.REFRESH_TOKEN, 60 * 60 * 24 * 14)) - .andDo(restDocs.document( - requestCookies( - cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN) - .optional() - .description("λ¦¬ν”„λ ˆμ‹œ 토큰") - ), - responseFields( - fieldWithPath("accessToken").description("μ•‘μ„ΈμŠ€ 토큰"), - fieldWithPath("userId").description("μœ μ € Id"), - fieldWithPath("role").description("μœ μ € κΆŒν•œ") - ), - responseCookies( - cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("λ¦¬ν”„λ ˆμ‹œ 토큰") - ) - )); - } - @Test @WithAnonymousUser @DisplayName("토큰 μž¬λ°œκΈ‰") @@ -159,7 +119,7 @@ void reissue() throws Exception { @DisplayName("토큰 μž¬λ°œκΈ‰ - λ¦¬ν”„λ ˆμ‹œ 토큰 헀더 μ—†λŠ” 경우") void reissue_invalidRefreshTokenHeader() throws Exception { //given - ErrorResponse response = new ErrorResponse(ErrorCode.INVALID_REFRESH_TOKEN_HEADER); + ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_REFRESH_TOKEN_HEADER); //when then mockMvc.perform(post("/auth/reissue")) @@ -171,7 +131,7 @@ void reissue_invalidRefreshTokenHeader() throws Exception { @DisplayName("토큰 μž¬λ°œκΈ‰ - λ¦¬ν”„λ ˆμ‹œ 토큰 헀더가 db에 μ—†λŠ” 경우") void reissue_refreshTokenNotFound() throws Exception { //given - ErrorResponse response = new ErrorResponse(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + ErrorResponse response = ErrorResponse.of(ErrorCode.REFRESH_TOKEN_NOT_FOUND); given(authService.reissue(anyString())) .willThrow(new BadRequestException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); @@ -186,7 +146,7 @@ void reissue_refreshTokenNotFound() throws Exception { @DisplayName("토큰 μž¬λ°œκΈ‰ - λ¦¬ν”„λ ˆμ‹œ 토큰 헀더가 db에 μžˆλŠ” κ°’κ³Ό μΌμΉ˜ν•˜μ§€ μ•Šμ€ 경우") void reissue_refreshTokenMismatched() throws Exception { //given - ErrorResponse response = new ErrorResponse(ErrorCode.REFRESH_TOKEN_MISMATCHED); + ErrorResponse response = ErrorResponse.of(ErrorCode.REFRESH_TOKEN_MISMATCHED); given(authService.reissue(anyString())) .willThrow(new BadRequestException(ErrorCode.REFRESH_TOKEN_MISMATCHED)); @@ -215,9 +175,6 @@ void signOut() throws Exception { .andExpect(cookie().maxAge(CustomHeader.CustomCookie.REFRESH_TOKEN, 0)) .andDo(restDocs.document( requestHeaders(authorizationHeader()), - requestCookies( - cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("λ¦¬ν”„λ ˆμ‹œ 토큰") - ), responseCookies( cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("λ¦¬ν”„λ ˆμ‹œ 토큰") ) @@ -231,7 +188,7 @@ void withdraw() throws Exception { //given //when then - mockMvc.perform(post("/auth/withdraw") + mockMvc.perform(delete("/auth/withdraw") .header(HttpHeaders.AUTHORIZATION, "Bearer accessToken")) .andExpect(status().isOk()) .andDo(restDocs.document( diff --git a/src/test/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractorTest.java b/src/test/java/com/chooz/auth/presentation/filter/HeaderTokenExtractorTest.java similarity index 88% rename from src/test/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractorTest.java rename to src/test/java/com/chooz/auth/presentation/filter/HeaderTokenExtractorTest.java index 5165e34f..50f62ae5 100644 --- a/src/test/java/com/swyp8team2/auth/presentation/filter/HeaderTokenExtractorTest.java +++ b/src/test/java/com/chooz/auth/presentation/filter/HeaderTokenExtractorTest.java @@ -1,14 +1,13 @@ -package com.swyp8team2.auth.presentation.filter; +package com.chooz.auth.presentation.filter; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.UnauthorizedException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.common.exception.UnauthorizedException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; class HeaderTokenExtractorTest { diff --git a/src/test/java/com/chooz/comment/application/CommentCommandServiceTest.java b/src/test/java/com/chooz/comment/application/CommentCommandServiceTest.java new file mode 100644 index 00000000..07432780 --- /dev/null +++ b/src/test/java/com/chooz/comment/application/CommentCommandServiceTest.java @@ -0,0 +1,112 @@ +package com.chooz.comment.application; + +import com.chooz.comment.domain.Comment; +import com.chooz.comment.domain.CommentRepository; +import com.chooz.comment.presentation.dto.CommentRequest; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.CommentFixture; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class CommentCommandServiceTest extends IntegrationTest { + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private CommentService commentService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + @Test + @DisplayName("λŒ“κΈ€ 생성 ν…ŒμŠ€νŠΈ") + void createComment() { + //given + Comment comment = makeComment(); + + //when + Comment savedComment = createAndGetSavedComment(comment); + + //then + assertAll( + () -> assertThat(comment.getUserId()).isEqualTo(savedComment.getUserId()), + () -> assertThat(comment.getPostId()).isEqualTo(savedComment.getPostId()), + () -> assertThat(comment.getContent()).isEqualTo(savedComment.getContent()), + () -> assertThat(comment.getEdited()).isEqualTo(savedComment.getEdited()) + ); + } + + @Test + @DisplayName("λŒ“κΈ€ μˆ˜μ •") + void updateComment() { + //given + Comment comment = makeComment(); + Comment savedComment = createAndGetSavedComment(comment); + CommentRequest updatedRequest = makeRequest(); + + //when + Long updateCommentId = commentService.updateComment( + savedComment.getPostId(), + savedComment.getId(), + updatedRequest, + savedComment.getUserId()).commentId(); + + // then + Comment updatedComment = commentRepository.findById(updateCommentId).orElseThrow(); + assertAll( + () -> assertThat(updatedComment.getId()).isEqualTo(savedComment.getId()), + () -> assertThat(updatedComment.getContent()).isEqualTo(updatedRequest.content()), + () -> assertThat(updatedComment.getEdited()).isTrue() + ); + } + + @Test + @DisplayName("λŒ“κΈ€ μ‚­μ œ") + void deleteComment() { + // given + Comment comment = makeComment(); + Comment savedComment = createAndGetSavedComment(comment); + + // when + commentService.deleteComment(savedComment.getPostId(), savedComment.getId(), savedComment.getUserId()); + + // then + assertThat(commentRepository.findById(savedComment.getId())).isEmpty(); + } + + private Comment makeComment() { + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + return CommentFixture.createCommentBuilder() + .userId(user.getId()) + .postId(post.getId()) + .build(); + } + + private Comment createAndGetSavedComment(Comment comment) { + CommentRequest request = new CommentRequest(comment.getContent()); + Long commentId = commentService.createComment( + comment.getPostId(), + request, + comment.getUserId()).commentId(); + return commentRepository.findById(commentId).orElseThrow(); + } + + private CommentRequest makeRequest() { + return new CommentRequest("This is a updated content"); + } +} diff --git a/src/test/java/com/chooz/comment/application/CommentQueryServiceTest.java b/src/test/java/com/chooz/comment/application/CommentQueryServiceTest.java new file mode 100644 index 00000000..6960b375 --- /dev/null +++ b/src/test/java/com/chooz/comment/application/CommentQueryServiceTest.java @@ -0,0 +1,152 @@ +package com.chooz.comment.application; + +import com.chooz.comment.domain.Comment; +import com.chooz.comment.domain.CommentRepository; +import com.chooz.comment.presentation.dto.CommentResponse; +import com.chooz.commentLike.domain.CommentLike; +import com.chooz.commentLike.domain.CommentLikeRepository; +import com.chooz.common.exception.BadRequestException; +import com.chooz.post.domain.CommentActive; +import com.chooz.post.domain.PollOption; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.CommentFixture; +import com.chooz.support.fixture.CommentLikeFixture; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class CommentQueryServiceTest extends IntegrationTest { + + @Autowired + private CommentQueryService commentQueryService; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private CommentLikeRepository commentLikeRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + @Test + @DisplayName("λŒ“κΈ€ λͺ©λ‘ 쑰회 ν…ŒμŠ€νŠΈ") + void findComments() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + Comment comment = commentRepository.save(CommentFixture.createDefaultComment(user.getId(), post.getId())); + CommentLike cl = commentLikeRepository.save(CommentLikeFixture.createDefaultCommentLike(user.getId(), comment.getId())); + createUserAndCommentLikesTimesOf(comment, 9); + + // when + CommentResponse response = + commentQueryService.findComments(post.getId(), user.getId(), null, 10); + + //then + assertAll( + () -> assertThat(response.commentCount()).isEqualTo(1), + () -> assertThat(response.comments().data()).hasSize(1), + () -> assertThat(response.comments().data().get(0).id()).isEqualTo(comment.getId()), + () -> assertThat(response.comments().data().get(0).content()).isEqualTo(comment.getContent()), + () -> assertThat(response.comments().data().get(0).edited()).isFalse(), + () -> assertThat(response.comments().data().get(0).author().userId()).isEqualTo(user.getId()), + () -> assertThat(response.comments().data().get(0).author().nickname()).isEqualTo(user.getNickname()), + () -> assertThat(response.comments().data().get(0).author().profileUrl()).isEqualTo(user.getProfileUrl()), + () -> assertThat(response.comments().data().get(0).like().commentLikeId()).isEqualTo(cl.getId()), + () -> assertThat(response.comments().data().get(0).like().likeCount()).isEqualTo(10), + () -> assertThat(response.comments().data().get(0).like().liked()).isTrue(), + () -> assertThat(response.comments().hasNext()).isFalse() + ); + } + + @Test + @DisplayName("λŒ“κΈ€ 20개 λͺ©λ‘ 쑰회 ν…ŒμŠ€νŠΈ") + void findComments20() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + Comment comment = commentRepository.save(CommentFixture.createDefaultComment(user.getId(), post.getId())); + createUserAndCommentsTimesOf(post, 19); + createUserAndCommentLikesTimesOf(comment, 9); + + // when + CommentResponse response = + commentQueryService.findComments(post.getId(), user.getId(), null, 10); + + //then + assertAll( + () -> assertThat(response.commentCount()).isEqualTo(20), + () -> assertThat(response.comments().data()).hasSize(10), + () -> assertThat(response.comments().hasNext()).isTrue() + ); + } + + @Test + @DisplayName("λŒ“κΈ€ 20개 μ»€μ„œ 11 λͺ©λ‘ 쑰회 ν…ŒμŠ€νŠΈ") + void findComments20Cursor11() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + Comment comment = commentRepository.save(CommentFixture.createDefaultComment(user.getId(), post.getId())); + createUserAndCommentsTimesOf(post, 19); + createUserAndCommentLikesTimesOf(comment, 9); + + // when + CommentResponse response = + commentQueryService.findComments(post.getId(), user.getId(), 11L, 10); + + //then + assertAll( + () -> assertThat(response.commentCount()).isEqualTo(20), + () -> assertThat(response.comments().hasNext()).isFalse() + ); + } + + @Test + @DisplayName("λŒ“κΈ€ λΉ„ν™œμ„±ν™” κ²Œμ‹œλ¬Ό 쑰회 ν…ŒμŠ€νŠΈ") + void findCommentsCloseCommentActive() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + PollOption pollOption = PostFixture.pollOptionBuilder() + .commentActive(CommentActive.CLOSED).build(); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(pollOption).build()); + Comment comment = commentRepository.save(CommentFixture.createDefaultComment(user.getId(), post.getId())); + createUserAndCommentsTimesOf(post, 19); + createUserAndCommentLikesTimesOf(comment, 9); + + // when & then + assertThatThrownBy(() -> commentQueryService.findComments(post.getId(), user.getId(), null, 10)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("λŒ“κΈ€ κΈ°λŠ₯이 λΉ„ν™œμ„±ν™” λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€."); + } + + + void createUserAndCommentLikesTimesOf(Comment comment, int times) { + for (int i = 0; i < times; i++) { + User user = userRepository.save(UserFixture.createDefaultUser()); + commentLikeRepository.save(CommentLikeFixture.createDefaultCommentLike(user.getId(), comment.getId())); + } + } + private void createUserAndCommentsTimesOf(Post post, int times) { + for (int i = 0; i < times; i++) { + User user = userRepository.save(UserFixture.createDefaultUser()); + commentRepository.save(CommentFixture.createDefaultComment(user.getId(), post.getId())); + } + } +} diff --git a/src/test/java/com/chooz/comment/presentation/CommentControllerTest.java b/src/test/java/com/chooz/comment/presentation/CommentControllerTest.java new file mode 100644 index 00000000..a4f516df --- /dev/null +++ b/src/test/java/com/chooz/comment/presentation/CommentControllerTest.java @@ -0,0 +1,194 @@ +package com.chooz.comment.presentation; + +import com.chooz.comment.presentation.dto.*; +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.support.RestDocsTest; +import com.chooz.support.WithMockUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class CommentControllerTest extends RestDocsTest { + + private final Long postId = 1L; + private final Long userId = 1L; + private final Long commentId = 10L; + + @Test + @WithMockUserInfo + @DisplayName("λŒ“κΈ€ λͺ©λ‘ 쑰회") + void findComments() throws Exception { + // given + int size = 10; + + List commentDtos = List.of( + new CommentDto( + 1L, + "comment1", + false, + LocalDateTime.of(2025, 2, 13, 12, 0), + new CommentAuthorDto(1L, "author", "https://image.chooz.site/profile-image"), + new CommentLikeDto(null, false, 10) + ), + new CommentDto( + 2L, + "comment2", + true, + LocalDateTime.of(2025, 2, 13, 12, 1), + new CommentAuthorDto(2L, "author2", "https://image.chooz.site/profile-image2"), + new CommentLikeDto(2L, true, 5) + ) + ); + + CommentResponse commentResponse = new CommentResponse( + 2, + CursorBasePaginatedResponse.of( new SliceImpl<>( + commentDtos, + PageRequest.of(0, size), + false + ))); + + given(commentService.findComments(postId, userId, null, size)) + .willReturn(commentResponse); + + // when then + mockMvc.perform(get("/posts/{postId}/comments", postId) + .param("cursor", "") + .param("size", String.valueOf(size)) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(commentResponse))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters(parameterWithName("postId").description("κ²Œμ‹œκΈ€ ID")), + queryParameters(cursorQueryParams()), + responseFields( + fieldWithPath("commentCount") + .type(JsonFieldType.NUMBER) + .description("κ²Œμ‹œκΈ€μ— μ†ν•œ λŒ“κΈ€ 수"), + fieldWithPath("comments.data[].id") + .type(JsonFieldType.NUMBER) + .description("λŒ“κΈ€ ID"), + fieldWithPath("comments.data[].content") + .type(JsonFieldType.STRING) + .description("λŒ“κΈ€ λ‚΄μš©"), + fieldWithPath("comments.data[].edited") + .type(JsonFieldType.BOOLEAN) + .description("μˆ˜μ • μ—¬λΆ€"), + fieldWithPath("comments.data[].createdAt") + .type(JsonFieldType.STRING) + .description("λŒ“κΈ€ μƒμ„±μ‹œκ°„"), + fieldWithPath("comments.data[].author.userId") + .type(JsonFieldType.NUMBER) + .description("μž‘μ„±μž ID"), + fieldWithPath("comments.data[].author.nickname") + .type(JsonFieldType.STRING) + .description("μž‘μ„±μž λ‹‰λ„€μž„"), + fieldWithPath("comments.data[].author.profileUrl") + .type(JsonFieldType.STRING) + .description("μž‘μ„±μž ν”„λ‘œν•„ 이미지 URL"), + fieldWithPath("comments.data[].like.commentLikeId") + .type(JsonFieldType.NUMBER) + .optional() + .description("λŒ“κΈ€ μ’‹μ•„μš” ID(μ’‹μ•„μš”λ₯Ό λˆ„λ₯΄μ§€ μ•Šμ€ 경우, null)"), + fieldWithPath("comments.data[].like.liked") + .type(JsonFieldType.BOOLEAN) + .description("λ‚΄κ°€ λŒ“κΈ€ μ’‹μ•„μš” λˆŒλ €λŠ”μ§€ μ—¬λΆ€"), + fieldWithPath("comments.data[].like.likeCount") + .type(JsonFieldType.NUMBER) + .description("λŒ“κΈ€ μ’‹μ•„μš” 수"), + fieldWithPath("comments.nextCursor") + .type(JsonFieldType.NUMBER) + .optional() + .description("λ‹€μŒ μ»€μ„œ"), + fieldWithPath("comments.hasNext") + .type(JsonFieldType.BOOLEAN) + .description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("λŒ“κΈ€ 생성") + void createComments() throws Exception { + CommentRequest request = new CommentRequest("ν…ŒμŠ€νŠΈ λŒ“κΈ€"); + + CommentIdResponse response = new CommentIdResponse(commentId); + + given(commentService.createComment(postId, request, 1L)) + .willReturn(response); + + mockMvc.perform(post("/posts/{postId}/comments", postId) + .header(HttpHeaders.AUTHORIZATION, "Bearer token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters(parameterWithName("postId").description("κ²Œμ‹œκΈ€ ID")), + requestFields(fieldWithPath("content").description("λŒ“κΈ€ λ‚΄μš©")), + responseFields( + fieldWithPath("commentId").description("λŒ“κΈ€ ID") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("λŒ“κΈ€ μˆ˜μ •") + void updateComment() throws Exception { + CommentRequest request = new CommentRequest("μˆ˜μ •λœ λŒ“κΈ€ λ‚΄μš©"); + + CommentIdResponse response = new CommentIdResponse(commentId); + + given(commentService.updateComment(postId, commentId, request, 1L)) + .willReturn(response); + + mockMvc.perform(patch("/posts/{postId}/comments/{commentId}", postId, commentId) + .header(HttpHeaders.AUTHORIZATION, "Bearer token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("κ²Œμ‹œκΈ€ ID"), + parameterWithName("commentId").description("λŒ“κΈ€ ID") + ), + requestFields(fieldWithPath("content").description("μˆ˜μ •ν•  λŒ“κΈ€ λ‚΄μš©")), + responseFields( + fieldWithPath("commentId").description("λŒ“κΈ€ ID") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("λŒ“κΈ€ μ‚­μ œ") + void deleteComment() throws Exception { + mockMvc.perform(delete("/posts/{postId}/comments/{commentId}", postId, commentId) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("κ²Œμ‹œκΈ€ ID"), + parameterWithName("commentId").description("λŒ“κΈ€ ID") + ) + )); + } +} diff --git a/src/test/java/com/chooz/commentLike/application/CommentLikeCommandServiceTest.java b/src/test/java/com/chooz/commentLike/application/CommentLikeCommandServiceTest.java new file mode 100644 index 00000000..8494f6d0 --- /dev/null +++ b/src/test/java/com/chooz/commentLike/application/CommentLikeCommandServiceTest.java @@ -0,0 +1,66 @@ +package com.chooz.commentLike.application; + +import com.chooz.comment.domain.Comment; +import com.chooz.comment.domain.CommentRepository; +import com.chooz.commentLike.domain.CommentLike; +import com.chooz.commentLike.domain.CommentLikeRepository; +import com.chooz.commentLike.presentation.dto.CommentLikeIdResponse; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.CommentFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommentLikeCommandServiceTest extends IntegrationTest { + + @Autowired + private CommentLikeRepository commentLikeRepository; + + @Autowired + private UserRepository userRepository; + @Autowired + private CommentRepository commentRepository; + + @Autowired + private CommentLikeService commentLikeService; + + @Test + @DisplayName("λŒ“κΈ€μ’‹μ•„μš” 생성") + void createCommentLike() { + //given, when + CommentLike commentLike = createAndGetSavedCommentLike(); + int count = commentLikeRepository.countByCommentId(commentLike.getCommentId()); + + //then + assertThat(commentLike).isNotNull(); + } + + @Test + @DisplayName("λŒ“κΈ€μ’‹μ•„μš” μ‚­μ œ") + void deleteCommentLike() { + //given + CommentLike commentLike = createAndGetSavedCommentLike(); + + // when + commentLikeService.deleteCommentLike(commentLike.getCommentId(),commentLike.getId(), commentLike.getUserId()); + int count = commentLikeRepository.countByCommentId(commentLike.getCommentId()); + // then + assertThat(commentLikeRepository.existsById(commentLike.getId())).isFalse(); + } + + private CommentLike createAndGetSavedCommentLike() { + Comment comment = commentRepository.save(CommentFixture.createCommentBuilder().build()); + User user = userRepository.save(UserFixture.createUserBuilder().build()); + + CommentLikeIdResponse commentLikeIdResponse = + commentLikeService.createCommentLike(comment.getId(), user.getId()); + + return commentLikeRepository + .findById(commentLikeIdResponse.commentLikeId()).orElseThrow(); + } +} diff --git a/src/test/java/com/chooz/commentLike/presentation/CommentLikeControllerTest.java b/src/test/java/com/chooz/commentLike/presentation/CommentLikeControllerTest.java new file mode 100644 index 00000000..bd7b85eb --- /dev/null +++ b/src/test/java/com/chooz/commentLike/presentation/CommentLikeControllerTest.java @@ -0,0 +1,83 @@ +package com.chooz.commentLike.presentation; + +import com.chooz.commentLike.presentation.dto.CommentLikeIdResponse; +import com.chooz.support.RestDocsTest; +import com.chooz.support.WithMockUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; + +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class CommentLikeControllerTest extends RestDocsTest { + private final Long userId = 1L; + private final Long commentId = 1L; + private final Long commentLikeId = 1L; + + @Test + @WithMockUserInfo + @DisplayName("λŒ“κΈ€ μ’‹μ•„μš” 생성") + void createCommentLike() throws Exception { + //given + CommentLikeIdResponse commentLikeIdResponse = + new CommentLikeIdResponse(commentLikeId, 11); + given(commentLikeService.createCommentLike(commentId, userId)) + .willReturn(commentLikeIdResponse); + + //when then + mockMvc.perform(post("/comment-likes/{commentId}", commentId) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters(parameterWithName("commentId").description("λŒ“κΈ€ ID")), + responseFields( + fieldWithPath("commentLikeId") + .type(JsonFieldType.NUMBER) + .description("λŒ“κΈ€μ’‹μ•„μš” ID"), + fieldWithPath("likeCount") + .type(JsonFieldType.NUMBER) + .description("λŒ“κΈ€μ’‹μ•„μš” 수") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("λŒ“κΈ€ μ’‹μ•„μš” μ‚­μ œ") + void deleteCommentLike() throws Exception { + //given + CommentLikeIdResponse commentLikeIdResponse = + new CommentLikeIdResponse(null, 10); + given(commentLikeService.deleteCommentLike(commentId, commentLikeId, userId)) + .willReturn(commentLikeIdResponse); + //when then + mockMvc.perform(delete("/comment-likes/{commentId}/{commentLikeId}", commentId, commentLikeId) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("commentId").description("λŒ“κΈ€ ID"), + parameterWithName("commentLikeId").description("λŒ“κΈ€ μ’‹μ•„μš” ID")), + responseFields( + fieldWithPath("commentLikeId") + .type(JsonFieldType.NUMBER) + .description("λŒ“κΈ€μ’‹μ•„μš” ID") + .optional(), + fieldWithPath("likeCount") + .type(JsonFieldType.NUMBER) + .description("λŒ“κΈ€μ’‹μ•„μš” 수") + ) + )); + } +} diff --git a/src/test/java/com/chooz/image/application/ImageServiceTest.java b/src/test/java/com/chooz/image/application/ImageServiceTest.java new file mode 100644 index 00000000..400b6dbf --- /dev/null +++ b/src/test/java/com/chooz/image/application/ImageServiceTest.java @@ -0,0 +1,69 @@ +package com.chooz.image.application; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.image.application.dto.PresignedUrlRequestDto; +import com.chooz.image.presentation.dto.PresignedUrlRequest; +import com.chooz.image.presentation.dto.PresignedUrlResponse; +import com.chooz.support.IntegrationTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +class ImageServiceTest extends IntegrationTest { + + @Autowired + ImageService imageService; + + @MockitoBean + S3Client s3Client; + + @MockitoBean + ImageNameGenerator imageNameGenerator; + + @Autowired + ImageProperties imageProperties; + + @Test + @DisplayName("presigned url 생성") + void getPresignedUrl() throws Exception { + //given + PresignedUrlRequest request = new PresignedUrlRequest(12345L, "image/jpeg"); + String presignedUrl = "https://example.com/presigned-url"; + String imageName = "test-image"; + given(s3Client.getPresignedPutUrl(any(PresignedUrlRequestDto.class))) + .willReturn(presignedUrl); + given(imageNameGenerator.generate()) + .willReturn(imageName); + + //when + PresignedUrlResponse response = imageService.getPresignedUrl(request); + + //then + assertAll( + () -> assertThat(response.signedUploadPutUrl()).isEqualTo(presignedUrl), + () -> assertThat(response.signedGetUrl()).isEqualTo(imageProperties.endpoint() + imageProperties.path() + imageName), + () -> assertThat(response.assetUrl()).isEqualTo(imageProperties.path() + imageName) + ); + } + + @Test + @DisplayName("presigned url 생성 - μ§€μ›ν•˜μ§€ μ•ŠλŠ” 컨텐츠 νƒ€μž…") + void getPresignedUrl_unsupportedContentType() throws Exception { + //given + PresignedUrlRequest request = new PresignedUrlRequest(12345L, "unsupported/type"); + + //when then + assertThatThrownBy(() -> imageService.getPresignedUrl(request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.UNSUPPORTED_IMAGE_EXTENSION.getMessage()); + } +} diff --git a/src/test/java/com/chooz/image/presentation/ImageControllerTest.java b/src/test/java/com/chooz/image/presentation/ImageControllerTest.java new file mode 100644 index 00000000..8c4f2dc7 --- /dev/null +++ b/src/test/java/com/chooz/image/presentation/ImageControllerTest.java @@ -0,0 +1,70 @@ +package com.chooz.image.presentation; + +import com.chooz.image.presentation.dto.PresignedUrlRequest; +import com.chooz.image.presentation.dto.PresignedUrlResponse; +import com.chooz.support.RestDocsTest; +import com.chooz.support.WithMockUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +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.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ImageControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("이미지 μ—…λ‘œλ“œ") + void createPresignedUrl() throws Exception { + //given + PresignedUrlRequest request = new PresignedUrlRequest(12345L, "image/jpg"); + PresignedUrlResponse response = new PresignedUrlResponse( + "https://presigned-url.com/upload-url", + "https://example.com/images/image.jpg", + "images/image.jpg" + ); + when(imageService.getPresignedUrl(any(PresignedUrlRequest.class))) + .thenReturn(response); + + //when then + mockMvc.perform(MockMvcRequestBuilders.post("/image/upload") + .header(HttpHeaders.AUTHORIZATION, "Bearer token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + requestFields( + fieldWithPath("contentType") + .description("μ΄λ―Έμ§€μ˜ Content-Type (예: image/jpg)") + .type(JsonFieldType.STRING), + fieldWithPath("contentLength") + .description("이미지 파일 크기 (λ°”μ΄νŠΈ λ‹¨μœ„)") + .type(JsonFieldType.NUMBER) + ), + responseFields( + fieldWithPath("signedUploadPutUrl") + .description("이미지 μ—…λ‘œλ“œ presigned URL (이미지λ₯Ό ν•΄λ‹Ή URL둜 PUT μš”μ²­μ„ 보내야 함, 만료 μ‹œκ°„ 5λΆ„)") + .type(JsonFieldType.STRING), + fieldWithPath("signedGetUrl") + .description("이미지 쑰회 전체 μ£Όμ†Œ") + .type(JsonFieldType.STRING), + fieldWithPath("assetUrl") + .description("이미지 μ €μž₯ 경둜") + .type(JsonFieldType.STRING) + ) + )); + } +} diff --git a/src/test/java/com/chooz/image/util/ImageValidatorTest.java b/src/test/java/com/chooz/image/util/ImageValidatorTest.java new file mode 100644 index 00000000..b1acd18d --- /dev/null +++ b/src/test/java/com/chooz/image/util/ImageValidatorTest.java @@ -0,0 +1,35 @@ +package com.chooz.image.util; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.image.application.ImageValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ImageValidatorTest { + + private ImageValidator imageValidator; + + @BeforeEach + void setUp() { + Set allowedExtensions = Set.of("gif" ,"jpg", "jpeg", "png", "webp", "heic", "heif"); + imageValidator = new ImageValidator(allowedExtensions); + } + + @Test + @DisplayName("파일 μœ νš¨μ„± 체크 - μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν™•μž₯자") + void validate_unsupportedContentType() { + // given + String unsupportedContentType = "txt"; + + // when then + assertThatThrownBy(() -> imageValidator.validate(unsupportedContentType)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.UNSUPPORTED_IMAGE_EXTENSION.getMessage()); + } +} diff --git a/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java b/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java new file mode 100644 index 00000000..ecd4d68a --- /dev/null +++ b/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java @@ -0,0 +1,98 @@ +package com.chooz.notification.application; + +import com.chooz.comment.domain.Comment; +import com.chooz.comment.domain.CommentRepository; +import com.chooz.commentLike.application.CommentLikeService; +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.TargetType; +import com.chooz.post.domain.PollChoiceRepository; +import com.chooz.post.domain.Post; +import com.chooz.post.persistence.PostJpaRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.CommentFixture; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.persistence.VoteJpaRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.transaction.TestTransaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class CommentLikeNotificationListenerTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostJpaRepository postRepository; + + @Autowired + VoteJpaRepository voteRepository; + + @Autowired + PollChoiceRepository pollChoiceRepository; + + @Autowired + CommentRepository commentRepository; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @Autowired + CommentLikeService commentLikeService; + + @AfterEach + void tearDown() { + voteRepository.deleteAllInBatch(); + pollChoiceRepository.deleteAllInBatch(); + postRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("λŒ“κΈ€μ’‹μ•„μš” μ•Œλ¦Ό") + void onCommentLiked() throws Exception { + //given + User receiver = userRepository.save(UserFixture.createDefaultUser()); + User actor = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder().userId(receiver.getId()).build()); + Comment comment = commentRepository.save(CommentFixture.createCommentBuilder() + .postId(post.getId()) + .userId(receiver.getId()) + .build()); + + //when + commentLikeService.createCommentLike(comment.getId(), actor.getId()); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + receiver.getId(), + null, + PageRequest.ofSize(10) + ).getContent().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("μ’‹μ•„μš”λ₯Ό λˆŒλ €μ–΄μš”!"), + () -> assertThat(notification.notificationRowDto().content()).contains("ν™•μΈν•΄λ³΄μ„Έμš”."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(actor.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(2) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } +} diff --git a/src/test/java/com/chooz/notification/application/MyPostClosedNotificationListenerTest.java b/src/test/java/com/chooz/notification/application/MyPostClosedNotificationListenerTest.java new file mode 100644 index 00000000..1d3e4764 --- /dev/null +++ b/src/test/java/com/chooz/notification/application/MyPostClosedNotificationListenerTest.java @@ -0,0 +1,180 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.NotificationType; +import com.chooz.notification.domain.TargetType; +import com.chooz.post.application.DateCloseScheduler; +import com.chooz.post.application.PostCommandService; +import com.chooz.post.application.PostVotedEventListener; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.PollChoiceRepository; +import com.chooz.post.domain.Post; +import com.chooz.post.persistence.PostJpaRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.application.VotedEvent; +import com.chooz.vote.persistence.VoteJpaRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.transaction.TestTransaction; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MyPostClosedNotificationListenerTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostJpaRepository postRepository; + + @Autowired + VoteJpaRepository voteRepository; + + @Autowired + PollChoiceRepository pollChoiceRepository; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @Autowired + PostVotedEventListener postVotedEventListener; + + @Autowired + DateCloseScheduler dateCloseScheduler; + + @Autowired + PostCommandService postCommandService; + + @AfterEach + void tearDown() { + voteRepository.deleteAllInBatch(); + pollChoiceRepository.deleteAllInBatch(); + postRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("λ‚΄ νˆ¬ν‘œ 마감 μ•Œλ¦Ό(μ°Έμ—¬μž 수 마감)") + void onMyPostClosedByVoter() throws Exception { + //given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user1.getId()) + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.VOTER) + .maxVoterCount(5) + .build()) + .build()); + + //when + int voterCount = 5; + for (int i = 0; i < voterCount; i++) { + User user = userRepository.save(UserFixture.createDefaultUser()); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + postVotedEventListener.handle(new VotedEvent(post.getId(), List.of(post.getPollChoices().get(0).getId()), user1.getId())); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + user1.getId(), + null, + PageRequest.ofSize(10) + ).getContent().stream().filter(notificationDto -> + notificationDto.notificationRowDto().notificationType().equals(NotificationType.MY_POST_CLOSED)) + .toList().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("νˆ¬ν‘œκ°€ λ§ˆκ°λμŠ΅λ‹ˆλ‹€!"), + () -> assertThat(notification.notificationRowDto().content()).contains("ν™•μΈν•΄λ³΄μ„Έμš”."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user1.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } + @Test + @DisplayName("λ‚΄ νˆ¬ν‘œ 마감 μ•Œλ¦Ό(μ‹œκ°„ 마감)") + void onMyPostClosedByDate() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .closeOption(PostFixture.createCloseOptionOverDate()) + .build()); + + // when + dateCloseScheduler.closePostsByDate(); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + user.getId(), + null, + PageRequest.ofSize(10) + ).getContent().stream().filter(notificationDto -> + notificationDto.notificationRowDto().notificationType().equals(NotificationType.MY_POST_CLOSED)) + .toList().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("νˆ¬ν‘œκ°€ λ§ˆκ°λμŠ΅λ‹ˆλ‹€!"), + () -> assertThat(notification.notificationRowDto().content()).contains("ν™•μΈν•΄λ³΄μ„Έμš”."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } + @Test + @DisplayName("λ‚΄ νˆ¬ν‘œ 마감 μ•Œλ¦Ό(직접 마감)") + void onMyPostClosedBySelf() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + + // when + postCommandService.close(user.getId(), post.getId()); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + List notifications = notificationQueryRepository.findNotifications( + user.getId(), + null, + PageRequest.ofSize(10) + ).getContent().stream().filter(notificationDto -> + notificationDto.notificationRowDto().notificationType().equals(NotificationType.MY_POST_CLOSED)) + .toList(); + + assertAll( + () -> assertThat(notifications).hasSize(0) + ); + } +} diff --git a/src/test/java/com/chooz/notification/application/NotificationInvalidListenerTest.java b/src/test/java/com/chooz/notification/application/NotificationInvalidListenerTest.java new file mode 100644 index 00000000..ac8627ed --- /dev/null +++ b/src/test/java/com/chooz/notification/application/NotificationInvalidListenerTest.java @@ -0,0 +1,136 @@ +package com.chooz.notification.application; + +import com.chooz.comment.application.CommentService; +import com.chooz.comment.domain.Comment; +import com.chooz.comment.domain.CommentRepository; +import com.chooz.commentLike.application.CommentLikeService; +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.post.application.PostCommandService; +import com.chooz.post.domain.PollChoiceRepository; +import com.chooz.post.domain.Post; +import com.chooz.post.persistence.PostJpaRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.CommentFixture; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.persistence.VoteJpaRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.transaction.TestTransaction; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class NotificationInvalidListenerTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostJpaRepository postRepository; + + @Autowired + VoteJpaRepository voteRepository; + + @Autowired + PollChoiceRepository pollChoiceRepository; + + @Autowired + CommentRepository commentRepository; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @Autowired + CommentLikeService commentLikeService; + + @Autowired + CommentService commentService; + + @Autowired + PostCommandService postCommandService; + + @AfterEach + void tearDown() { + voteRepository.deleteAllInBatch(); + pollChoiceRepository.deleteAllInBatch(); + postRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + private void commit(Runnable work) { + if(!TestTransaction.isActive()){ + TestTransaction.start(); + } + work.run(); + TestTransaction.flagForCommit(); + TestTransaction.end(); + } + + @Test + @DisplayName("λŒ“κΈ€μ’‹μ•„μš” 원 λŒ“κΈ€ μ‚­μ œ μ‹œ μ•Œλ¦Ό Invalid 처리") + void InvalidNotificationByDeleteComment() throws Exception { + //given + User receiver = userRepository.save(UserFixture.createDefaultUser()); + User actor = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder().userId(receiver.getId()).build()); + Comment comment = commentRepository.save(CommentFixture.createCommentBuilder() + .postId(post.getId()) + .userId(receiver.getId()) + .build()); + commit(() -> commentLikeService.createCommentLike(comment.getId(), actor.getId())); + + //when + commit(() -> commentService.deleteComment(post.getId(), comment.getId(), receiver.getId())); + + //then + List notifications = notificationQueryRepository.findNotifications( + receiver.getId(), + null, + PageRequest.ofSize(10) + ).getContent(); + assertAll( + () -> assertThat(notifications.size()).isZero() + ); + } + @Test + @DisplayName("원 κ²Œμ‹œλ¬Ό μ‚­μ œ μ‹œ μ•Œλ¦Ό Invalid 처리") + void InvalidNotificationByDeletePost() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + + // when + List users = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + User voteUser = userRepository.save(UserFixture.createDefaultUser()); + users.add(voteUser); + voteRepository.save(VoteFixture.createDefaultVote(voteUser.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + commit(() -> postCommandService.close(user.getId(), post.getId())); + + //when + commit(() -> postCommandService.delete(user.getId(), post.getId())); + + //then + List notifications = notificationQueryRepository.findNotifications( + users.get(0).getId(), + null, + PageRequest.ofSize(10) + ).getContent(); + + assertAll( + () -> assertThat(notifications.size()).isZero() + ); + } +} diff --git a/src/test/java/com/chooz/notification/application/NotificationQueryServiceTest.java b/src/test/java/com/chooz/notification/application/NotificationQueryServiceTest.java new file mode 100644 index 00000000..48ce32eb --- /dev/null +++ b/src/test/java/com/chooz/notification/application/NotificationQueryServiceTest.java @@ -0,0 +1,166 @@ +package com.chooz.notification.application; + +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.application.service.NotificationCommandService; +import com.chooz.notification.application.service.NotificationQueryService; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import com.chooz.notification.domain.Target; +import com.chooz.notification.domain.TargetType; +import com.chooz.notification.persistence.NotificationJpaRepository; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; +import com.chooz.notification.presentation.dto.NotificationResponse; +import com.chooz.support.IntegrationTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.transaction.AfterTransaction; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class NotificationQueryServiceTest extends IntegrationTest { + + @Autowired + NotificationQueryService notificationQueryService; + + @Autowired + NotificationCommandService notificationCommandService; + + @Autowired + NotificationJpaRepository notificationJpaRepository; + + @AfterTransaction + void clean() { + notificationJpaRepository.deleteAll(); + } + + @Test + @DisplayName("μ•Œλ¦Ό 쑰회") + void notifications() throws Exception { + //given + Long receiverId = 1L; + Long actorId = 2L; + String title = "μˆ¨κ²¨μ§„ μΈ„λ‹˜μ΄ μ’‹μ•„μš”λ₯Ό λˆŒλ €μ–΄μš”!"; + String content = "μ§€κΈˆ λ°”λ‘œ ν™•μΈν•΄λ³΄μ„Έμš”."; + String profileUrl = "https://cdn.chooz.site/default_profile.png"; + List targets = List.of(Target.of(3L, TargetType.POST)); + String imageUrl = "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d"; + LocalDateTime eventAt = LocalDateTime.now(); + NotificationType notificationType = NotificationType.COMMENT_LIKED; + + Notification notification = Notification.create( + notificationType, + eventAt, + NotificationContent.of( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ) + ).get(); + //when + notificationCommandService.create(notification); + List notifications = notificationQueryService.findNotifications( + receiverId, + null, + 10 + ).data(); + + //then + assertAll( + () -> assertThat(notifications.size()).isOne(), + () -> assertThat(notifications.getFirst().content()).isEqualTo(content), + () -> assertThat(notifications.getFirst().title()).isEqualTo(title), + () -> assertThat(notifications.getFirst().profileUrl()).isEqualTo(profileUrl), + () -> assertThat(notifications.getFirst().imageUrl()).isEqualTo(imageUrl), + () -> assertThat(notifications.getFirst().isRead()).isEqualTo(false), + () -> assertThat(notifications.getFirst().isValid()).isEqualTo(true) + + ); + } + @Test + @DisplayName("μ•Œλ¦Ό 10개 이상 쑰회") + void notificationsOver10() throws Exception { + //given + Long receiverId = 1L; + Long actorId = 2L; + String title = "μˆ¨κ²¨μ§„ μΈ„λ‹˜μ΄ μ’‹μ•„μš”λ₯Ό λˆŒλ €μ–΄μš”!"; + String content = "μ§€κΈˆ λ°”λ‘œ ν™•μΈν•΄λ³΄μ„Έμš”."; + String profileUrl = "https://cdn.chooz.site/default_profile.png"; + List targets = List.of(Target.of(3L, TargetType.POST)); + String imageUrl = "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d"; + LocalDateTime eventAt = LocalDateTime.now(); + NotificationType notificationType = NotificationType.COMMENT_LIKED; + for(long i = 0 ; i < 11 ; i++) { + Notification notification = Notification.create( + notificationType, + eventAt, + NotificationContent.of( + receiverId, + actorId + i, + title, + content, + profileUrl, + imageUrl, + targets + ) + ).get(); + notificationCommandService.create(notification); + } + //when + CursorBasePaginatedResponse notifications = notificationQueryService.findNotifications( + receiverId, + null, + 10 + ); + + //then + assertAll( + () -> assertThat(notifications.data().size()).isEqualTo(10), + () -> assertThat(notifications.hasNext()).isTrue() + ); + } + @Test + @DisplayName("μ•Œλ¦Ό μƒνƒœ 확인") + void present() throws Exception { + //given + Long receiverId = 1L; + Long actorId = 2L; + String title = "μˆ¨κ²¨μ§„ μΈ„λ‹˜μ΄ μ’‹μ•„μš”λ₯Ό λˆŒλ €μ–΄μš”!"; + String content = "μ§€κΈˆ λ°”λ‘œ ν™•μΈν•΄λ³΄μ„Έμš”."; + String profileUrl = "https://cdn.chooz.site/default_profile.png"; + List targets = List.of(Target.of(3L, TargetType.POST)); + String imageUrl = "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d"; + LocalDateTime eventAt = LocalDateTime.now(); + NotificationType notificationType = NotificationType.COMMENT_LIKED; + + Notification notification = Notification.create( + notificationType, + eventAt, + NotificationContent.of( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ) + ).get(); + //when + notificationCommandService.create(notification); + NotificationPresentResponse notificationPresentResponse = notificationQueryService.present(receiverId); + //then + assertAll( + () -> assertThat(notificationPresentResponse.present()).isTrue() + ); + } +} diff --git a/src/test/java/com/chooz/notification/application/PostClosedNotificationListenerTest.java b/src/test/java/com/chooz/notification/application/PostClosedNotificationListenerTest.java new file mode 100644 index 00000000..4361b327 --- /dev/null +++ b/src/test/java/com/chooz/notification/application/PostClosedNotificationListenerTest.java @@ -0,0 +1,198 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.TargetType; +import com.chooz.post.application.DateCloseScheduler; +import com.chooz.post.application.PostCommandService; +import com.chooz.post.application.PostVotedEventListener; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.PollChoiceRepository; +import com.chooz.post.domain.Post; +import com.chooz.post.persistence.PostJpaRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.application.VotedEvent; +import com.chooz.vote.persistence.VoteJpaRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.transaction.TestTransaction; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class PostClosedNotificationListenerTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostJpaRepository postRepository; + + @Autowired + VoteJpaRepository voteRepository; + + @Autowired + PollChoiceRepository pollChoiceRepository; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @Autowired + PostVotedEventListener postVotedEventListener; + + @Autowired + DateCloseScheduler dateCloseScheduler; + + @Autowired + PostCommandService postCommandService; + + @AfterEach + void tearDown() { + voteRepository.deleteAllInBatch(); + pollChoiceRepository.deleteAllInBatch(); + postRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("νˆ¬ν‘œ 마감 μ•Œλ¦Ό(μ°Έμ—¬μž 수 마감)") + void onPostClosedByVoter() throws Exception { + //given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user1.getId()) + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.VOTER) + .maxVoterCount(5) + .build()) + .build()); + + //when + int voterCount = 5; + List users = new ArrayList<>(); + for (int i = 0; i < voterCount; i++) { + User user = userRepository.save(UserFixture.createDefaultUser()); + users.add(user); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + postVotedEventListener.handle(new VotedEvent(post.getId(), List.of(post.getPollChoices().get(0).getId()), user1.getId())); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + users.get(0).getId(), + null, + PageRequest.ofSize(10) + ).getContent().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("당신이 μ°Έμ—¬ν•œ"), + () -> assertThat(notification.notificationRowDto().content()).contains("ν™•μΈν•΄λ³΄μ„Έμš”."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user1.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } + @Test + @DisplayName("νˆ¬ν‘œ 마감 μ•Œλ¦Ό(μ‹œκ°„ 마감)") + void onMyPostClosedByDate() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .closeOption(PostFixture.createCloseOptionOverDate()) + .build()); + + // when + List users = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + User voteUser = userRepository.save(UserFixture.createDefaultUser()); + users.add(voteUser); + voteRepository.save(VoteFixture.createDefaultVote(voteUser.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + dateCloseScheduler.closePostsByDate(); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + users.get(0).getId(), + null, + PageRequest.ofSize(10) + ).getContent().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("당신이 μ°Έμ—¬ν•œ"), + () -> assertThat(notification.notificationRowDto().content()).contains("ν™•μΈν•΄λ³΄μ„Έμš”."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } + @Test + @DisplayName("νˆ¬ν‘œ 마감 μ•Œλ¦Ό(직접 마감)") + void onMyPostClosedBySelf() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + + // when + List users = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + User voteUser = userRepository.save(UserFixture.createDefaultUser()); + users.add(voteUser); + voteRepository.save(VoteFixture.createDefaultVote(voteUser.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + postCommandService.close(user.getId(), post.getId()); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + users.get(0).getId(), + null, + PageRequest.ofSize(10) + ).getContent().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("당신이 μ°Έμ—¬ν•œ"), + () -> assertThat(notification.notificationRowDto().content()).contains("ν™•μΈν•΄λ³΄μ„Έμš”."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } +} diff --git a/src/test/java/com/chooz/notification/application/VotedNotificationListenerTest.java b/src/test/java/com/chooz/notification/application/VotedNotificationListenerTest.java new file mode 100644 index 00000000..3ccd7610 --- /dev/null +++ b/src/test/java/com/chooz/notification/application/VotedNotificationListenerTest.java @@ -0,0 +1,95 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.TargetType; +import com.chooz.post.domain.PollChoice; +import com.chooz.post.domain.PollChoiceRepository; +import com.chooz.post.domain.Post; +import com.chooz.post.persistence.PostJpaRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.application.VoteService; +import com.chooz.vote.persistence.VoteJpaRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.transaction.TestTransaction; + +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class VotedNotificationListenerTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostJpaRepository postRepository; + + @Autowired + VoteJpaRepository voteRepository; + + @Autowired + VoteService voteService; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @Autowired + PollChoiceRepository pollChoiceRepository; + + @AfterEach + void tearDown() { + voteRepository.deleteAllInBatch(); + pollChoiceRepository.deleteAllInBatch(); + postRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("νˆ¬ν‘œμ°Έμ—¬ μ•Œλ¦Ό") + void onVoted() throws Exception { + //given + User receiver = userRepository.save(UserFixture.createDefaultUser()); + User actor = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder().userId(receiver.getId()).build()); + + //when + voteService.vote( + actor.getId(), + post.getId(), + post.getPollChoices().stream().map(PollChoice::getId).limit(1).collect(Collectors.toList())); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + receiver.getId(), + null, + PageRequest.ofSize(10) + ).getContent().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("νˆ¬ν‘œμ— μ°Έμ—¬ν–ˆμ–΄μš”!"), + () -> assertThat(notification.notificationRowDto().content()).contains("ν™•μΈν•΄λ³΄μ„Έμš”."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(actor.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } +} diff --git a/src/test/java/com/chooz/notification/domain/NotificationTest.java b/src/test/java/com/chooz/notification/domain/NotificationTest.java new file mode 100644 index 00000000..2736b23c --- /dev/null +++ b/src/test/java/com/chooz/notification/domain/NotificationTest.java @@ -0,0 +1,96 @@ +package com.chooz.notification.domain; + +import com.chooz.notification.application.dto.NotificationContent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class NotificationTest { + + @Test + @DisplayName("μ•Œλ¦Ό 생성") + void create() throws Exception { + //given + Long receiverId = 1L; + Long actorId = 2L; + String title = "μˆ¨κ²¨μ§„ μΈ„λ‹˜μ΄ μ’‹μ•„μš”λ₯Ό λˆŒλ €μ–΄μš”!"; + String content = "μ§€κΈˆ λ°”λ‘œ ν™•μΈν•΄λ³΄μ„Έμš”."; + String profileUrl = "https://cdn.chooz.site/default_profile.png"; + List targets = List.of(Target.of(3L, TargetType.POST)); + String imageUrl = "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d"; + LocalDateTime eventAt = LocalDateTime.now(); + NotificationType notificationType = NotificationType.COMMENT_LIKED; + + Notification notification = Notification.create( + notificationType, + eventAt, + NotificationContent.of( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ) + ).get(); + + //when then + assertAll( + () -> assertThat(notification.getReceiverId()).isEqualTo(receiverId), + () -> assertThat(notification.getProfileUrl()).isEqualTo(profileUrl), + () -> assertThat(notification.getTitle()).isEqualTo(title), + () -> assertThat(notification.getContent()).isEqualTo(content), + () -> assertThat(notification.getTargets()) + .allSatisfy(target -> { + assertThat(target.getId()).isEqualTo(3L); + assertThat(target.getType()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.getImageUrl()).isEqualTo(imageUrl), + () -> assertThat(notification.isRead()).isEqualTo(false), + () -> assertThat(notification.getEventAt()).isEqualTo(eventAt) + ); + } + @Test + @DisplayName("μ•Œλ¦Ό 읽음 확인") + void markRead() throws Exception { + //given + Long receiverId = 1L; + Long actorId = 2L; + String title = "μˆ¨κ²¨μ§„ μΈ„λ‹˜μ΄ μ’‹μ•„μš”λ₯Ό λˆŒλ €μ–΄μš”!"; + String content = "μ§€κΈˆ λ°”λ‘œ ν™•μΈν•΄λ³΄μ„Έμš”."; + String profileUrl = "https://cdn.chooz.site/default_profile.png"; + List targets = List.of(Target.of(3L, TargetType.POST)); + String imageUrl = "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d"; + LocalDateTime eventAt = LocalDateTime.now(); + NotificationType notificationType = NotificationType.COMMENT_LIKED; + + Notification notification = Notification.create( + notificationType, + eventAt, + NotificationContent.of( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ) + ).get(); + + //when + notification.markRead(); + + //then + assertAll( + () -> assertThat(notification.isRead()).isTrue() + ); + } +} diff --git a/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java b/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java new file mode 100644 index 00000000..1cb944a8 --- /dev/null +++ b/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java @@ -0,0 +1,132 @@ +package com.chooz.notification.presentation; + +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.notification.domain.NotificationType; +import com.chooz.notification.domain.Target; +import com.chooz.notification.domain.TargetType; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; +import com.chooz.notification.presentation.dto.NotificationResponse; +import com.chooz.support.RestDocsTest; +import com.chooz.support.WithMockUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.payload.JsonFieldType; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class NotificationControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("μ•Œλ¦Ό λͺ©λ‘ 쑰회") + void findNotifications() throws Exception { + //given + var response = new CursorBasePaginatedResponse<>( + 1L, + false, + List.of( + new NotificationResponse( + 1L, + NotificationType.COMMENT_LIKED, + "https://cdn.chooz.site/default_profile.png", + "μˆ¨κ²¨μ§„ μΈ„λ‹˜μ΄ μ’‹μ•„μš”λ₯Ό λˆŒλ €μ–΄μš”!", + "μ§€κΈˆ λ°”λ‘œ ν™•μΈν•΄λ³΄μ„Έμš”.", + "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d", + List.of(Target.of(1L, TargetType.POST)), + true, + false, + LocalDateTime.now() + ) + ) + ); + given(notificationService.findNotifications(1L, null, 10)).willReturn(response); + + //when then + mockMvc.perform(get("/notifications") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + queryParameters(cursorQueryParams()), + responseFields( + fieldWithPath("nextCursor").type(JsonFieldType.NUMBER).optional() + .description("λ‹€μŒ 쑰회 μ»€μ„œ κ°’"), + fieldWithPath("hasNext") + .type(JsonFieldType.BOOLEAN).description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€ (κΈ°λ³Έ κ°’ 10)"), + fieldWithPath("data[]") + .type(JsonFieldType.ARRAY).description("μ•Œλ¦Ό 데이터"), + fieldWithPath("data[].id") + .type(JsonFieldType.NUMBER).description("μ•Œλ¦Ό ID"), + fieldWithPath("data[].notificationType") + .type(JsonFieldType.STRING).description("μ•Œλ¦Ό μœ ν˜•"), + fieldWithPath("data[].title") + .type(JsonFieldType.STRING).description("μ•Œλ¦Ό λ‚΄μš©(제λͺ©)"), + fieldWithPath("data[].content") + .type(JsonFieldType.STRING).description("μ•Œλ¦Ό λ‚΄μš©(λ‚΄μš©)"), + fieldWithPath("data[].profileUrl") + .type(JsonFieldType.STRING).description("μ•Œλ¦Ό ν”„λ‘œν•„ 이미지 url"), + fieldWithPath("data[].imageUrl") + .type(JsonFieldType.STRING).description("μ•Œλ¦Ό 썸넀일 이미지 url"), + fieldWithPath("data[].targets[].id") + .type(JsonFieldType.NUMBER).description("μ•Œλ¦Ό νƒ€κ²Ÿ ID"), + fieldWithPath("data[].targets[].type") + .type(JsonFieldType.STRING).description("μ•Œλ¦Ό νƒ€κ²Ÿ μœ ν˜•"), + fieldWithPath("data[].isValid") + .type(JsonFieldType.BOOLEAN).description("유효 μ•Œλ¦Ό μ—¬λΆ€"), + fieldWithPath("data[].isRead") + .type(JsonFieldType.BOOLEAN).description("읽음 μ—¬λΆ€"), + fieldWithPath("data[].eventAt") + .type(JsonFieldType.STRING).description("이벀트 λ°œμƒ μ‹œκ°„") + ) + )); + } + @Test + @WithMockUserInfo + @DisplayName("μ•Œλ¦Ό 읽기") + void markRead() throws Exception { + //when then + mockMvc.perform(patch("/notifications/{notificationId}", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters(parameterWithName("notificationId").description("μ•Œλ¦Ό ID")) + )); + verify(notificationService, times(1)).markRead(any()); + } + @Test + @WithMockUserInfo + @DisplayName("μ•Œλ¦Ό μƒνƒœ 확인") + void present() throws Exception { + NotificationPresentResponse response = NotificationPresentResponse.of(true); + given(notificationService.present(1L)).willReturn(response); + //when then + mockMvc.perform(get("/notifications/present") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + responseFields(fieldWithPath("present").type(JsonFieldType.BOOLEAN).description("μ•Œλ¦Ό μƒνƒœ μ—¬λΆ€")) + )); + verify(notificationService, times(1)).present(any()); + } +} diff --git a/src/test/java/com/chooz/post/application/CloseEventListenerTest.java b/src/test/java/com/chooz/post/application/CloseEventListenerTest.java new file mode 100644 index 00000000..43cd9389 --- /dev/null +++ b/src/test/java/com/chooz/post/application/CloseEventListenerTest.java @@ -0,0 +1,63 @@ +package com.chooz.post.application; + +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.post.domain.Status; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.application.VotedEvent; +import com.chooz.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CloseEventListenerTest extends IntegrationTest { + + @Autowired + PostVotedEventListener postVotedEventListener; + + @Autowired + UserRepository userRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + VoteRepository voteRepository; + + @Test + @DisplayName("μ°Έμ—¬μž 수둜 마감") + void handle() throws Exception { + //given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user1.getId()) + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.VOTER) + .maxVoterCount(5) + .build()) + .build()); + int voterCount = 5; + for (int i = 0; i < voterCount; i++) { + User user = userRepository.save(UserFixture.createDefaultUser()); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + + //when + postVotedEventListener.handle(new VotedEvent(post.getId(), List.of(post.getPollChoices().get(0).getId()), user1.getId())); + + //then + Post findPost = postRepository.findById(post.getId()).get(); + assertThat(findPost.getStatus()).isEqualTo(Status.CLOSED); + } +} diff --git a/src/test/java/com/chooz/post/application/DateCloseSchedulerTest.java b/src/test/java/com/chooz/post/application/DateCloseSchedulerTest.java new file mode 100644 index 00000000..777b0413 --- /dev/null +++ b/src/test/java/com/chooz/post/application/DateCloseSchedulerTest.java @@ -0,0 +1,45 @@ +package com.chooz.post.application; + +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.post.domain.Status; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; + +class DateCloseSchedulerTest extends IntegrationTest { + + @Autowired + DateCloseScheduler dateCloseScheduler; + + @Autowired + PostRepository postRepository; + + @Autowired + UserRepository userRepository; + + @Test + @DisplayName("마감 기간이 μ§€λ‚˜λ©΄ νˆ¬ν‘œ λ§ˆκ°λ˜μ–΄μ•Ό 함") + void closePostsByDate() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .closeOption(PostFixture.createCloseOptionOverDate()) + .build()); + + // when + dateCloseScheduler.closePostsByDate(); + + // then + Post find = postRepository.findById(post.getId()).orElseThrow(); + assertThat(find.getStatus()).isEqualTo(Status.CLOSED); + } +} diff --git a/src/test/java/com/swyp8team2/post/application/PostImageNameGeneratorTest.java b/src/test/java/com/chooz/post/application/PollChoiceNameGeneratorTest.java similarity index 56% rename from src/test/java/com/swyp8team2/post/application/PostImageNameGeneratorTest.java rename to src/test/java/com/chooz/post/application/PollChoiceNameGeneratorTest.java index 626bb21f..748b9079 100644 --- a/src/test/java/com/swyp8team2/post/application/PostImageNameGeneratorTest.java +++ b/src/test/java/com/chooz/post/application/PollChoiceNameGeneratorTest.java @@ -1,19 +1,18 @@ -package com.swyp8team2.post.application; +package com.chooz.post.application; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -class PostImageNameGeneratorTest { +class PollChoiceNameGeneratorTest { - PostImageNameGenerator postImageNameGenerator; + PollChoiceNameGenerator pollChoiceNameGenerator; @BeforeEach void setUp() { - postImageNameGenerator = new PostImageNameGenerator(); + pollChoiceNameGenerator = new PollChoiceNameGenerator(); } @Test @@ -22,8 +21,8 @@ void generate() throws Exception { //given //when - String generate1 = postImageNameGenerator.generate(); - String generate2 = postImageNameGenerator.generate(); + String generate1 = pollChoiceNameGenerator.generate(); + String generate2 = pollChoiceNameGenerator.generate(); //then assertThat(generate1).isEqualTo("λ½€λ˜A"); diff --git a/src/test/java/com/chooz/post/application/PostCommandServiceTest.java b/src/test/java/com/chooz/post/application/PostCommandServiceTest.java new file mode 100644 index 00000000..620c259b --- /dev/null +++ b/src/test/java/com/chooz/post/application/PostCommandServiceTest.java @@ -0,0 +1,461 @@ +package com.chooz.post.application; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.*; +import com.chooz.post.presentation.dto.CloseOptionDto; +import com.chooz.post.presentation.dto.CreatePostRequest; +import com.chooz.post.presentation.dto.CreatePostResponse; +import com.chooz.post.presentation.dto.PollChoiceRequestDto; +import com.chooz.post.presentation.dto.PollOptionDto; +import com.chooz.post.presentation.dto.UpdatePostRequest; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.thumbnail.domain.Thumbnail; +import com.chooz.thumbnail.domain.ThumbnailRepository; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +public class PostCommandServiceTest extends IntegrationTest { + + @Autowired + PostService postService; + + @Autowired + PostRepository postRepository; + + @Autowired + UserRepository userRepository; + + @MockitoBean + ShareUrlService shareUrlService; + + @Autowired + ThumbnailRepository thumbnailRepository; + + @Autowired + VoteRepository voteRepository; + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μž‘μ„±") + void create() throws Exception { + //given + long userId = 1L; + CreatePostRequest request = new CreatePostRequest( + "title", + "description", + List.of( + new PollChoiceRequestDto("title1", "http://image1.com"), + new PollChoiceRequestDto("title2", "http://image2.com") + ), + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.SELF, null, null) + ); + String shareUrl = "shareUrl"; + given(shareUrlService.generateShareUrl()) + .willReturn("shareUrl"); + + //when + CreatePostResponse response = postService.create(userId, request); + + //then + Post post = postRepository.findById(response.postId()).get(); + Thumbnail thumbnail = thumbnailRepository.findByPostId(post.getId()).get(); + List pollChoices = post.getPollChoices(); + assertAll( + () -> assertThat(post.getDescription()).isEqualTo("description"), + () -> assertThat(post.getUserId()).isEqualTo(userId), + () -> assertThat(post.getShareUrl()).isEqualTo(shareUrl), + () -> assertThat(post.getStatus()).isEqualTo(Status.PROGRESS), + () -> assertThat(post.getPollOption().getPollType()).isEqualTo(PollType.SINGLE), + + () -> assertThat(pollChoices).hasSize(2), + () -> assertThat(pollChoices.get(0).getImageUrl()).isEqualTo("http://image1.com"), + () -> assertThat(pollChoices.get(0).getTitle()).isEqualTo("title1"), + () -> assertThat(pollChoices.get(1).getImageUrl()).isEqualTo("http://image2.com"), + () -> assertThat(pollChoices.get(1).getTitle()).isEqualTo("title2"), + + () -> assertThat(thumbnail.getThumbnailUrl()).isEqualTo("http://image1.com"), + () -> assertThat(thumbnail.getPostId()).isEqualTo(post.getId()), + () -> assertThat(thumbnail.getPollChoiceId()).isEqualTo(pollChoices.get(0).getId()) + ); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μž‘μ„± - 이미지가 2개 미만인 경우") + void create_invalidPollChoiceCount() throws Exception { + //given + long userId = 1L; + CreatePostRequest request = new CreatePostRequest( + "title", + "description", + List.of( + new PollChoiceRequestDto("title1", "http://image1.com") + ), + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.SELF, null, null) + ); + //when then + assertThatThrownBy(() -> postService.create(userId, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_POLL_CHOICE_COUNT.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μž‘μ„± - μ„€λͺ…이 100자 λ„˜μ–΄κ°€λŠ” 경우") + void create_descriptionCountExceeded() throws Exception { + //given + long userId = 1L; + CreatePostRequest request = new CreatePostRequest( + "title", + "a".repeat(101), + List.of( + new PollChoiceRequestDto("title1", "http://image1.com"), + new PollChoiceRequestDto("title2", "http://image2.com") + ), + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.SELF, null, null) + ); + + //when then + assertThatThrownBy(() -> postService.create(userId, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μž‘μ„± - 제λͺ©μ΄ 50자 λ„˜μ–΄κ°€λŠ” 경우") + void create_titleCountExceeded() throws Exception { + //given + long userId = 1L; + CreatePostRequest request = new CreatePostRequest( + "a".repeat(51), + "description", + List.of( + new PollChoiceRequestDto("title1", "http://image1.com"), + new PollChoiceRequestDto("title2", "http://image2.com") + ), + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.SELF, null, null) + ); + + //when then + assertThatThrownBy(() -> postService.create(userId, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.TITLE_LENGTH_EXCEEDED.getMessage()); + } + + @Test + @DisplayName("νˆ¬ν‘œ 마감") + void close() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + + //when + postService.close(user.getId(), post.getId()); + + //then + postRepository.findById(post.getId()).get(); + assertThat(post.getStatus()).isEqualTo(Status.CLOSED); + } + + @Test + @DisplayName("νˆ¬ν‘œ 마감 - κ²Œμ‹œκΈ€ μž‘μ„±μžκ°€ 아닐 경우") + void close_notPostAuthor() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + User anotherUser = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + + //when then + assertThatThrownBy(() -> postService.close(anotherUser.getId(), post.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); + } + + @Test + @DisplayName("νˆ¬ν‘œ 마감 - 이미 마감된 κ²Œμ‹œκΈ€μΈ 경우") + void close_alreadyClosed() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .userId(user.getId()) + .status(Status.CLOSED) + .build() + ); + + //when then + assertThatThrownBy(() -> postService.close(user.getId(), post.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + } + + @Test + @DisplayName("νˆ¬ν‘œ 마감 - μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” κ²Œμ‹œκΈ€μΌ 경우") + void close_notFoundPost() throws Exception { + //given + + //when then + assertThatThrownBy(() -> postService.close(1L, 1L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μ‚­μ œ") + void delete() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + + //when + postService.delete(user.getId(), post.getId()); + + //then + assertThat(postRepository.findById(post.getId())).isEmpty(); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ •") + void update() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + UpdatePostRequest request = new UpdatePostRequest( + "Updated Title", + "Updated Description", + List.of( + new PollChoiceRequestDto("title1", "http://image1.com"), + new PollChoiceRequestDto("title2", "http://image2.com") + ), + new PollOptionDto(Scope.PRIVATE, PollType.MULTIPLE, CommentActive.CLOSED), + new CloseOptionDto(CloseType.SELF, null, null) + ); + + //when + postService.update(user.getId(), post.getId(), request); + + //then + Post updatedPost = postRepository.findById(post.getId()).orElseThrow(); + assertAll( + () -> assertThat(updatedPost.getTitle()).isEqualTo(request.title()), + () -> assertThat(updatedPost.getDescription()).isEqualTo(request.description()), + () -> assertThat(updatedPost.getPollOption().getPollType()).isEqualTo(request.pollOption().pollType()), + () -> assertThat(updatedPost.getPollOption().getScope()).isEqualTo(request.pollOption().scope()), + () -> assertThat(updatedPost.getPollOption().getCommentActive()).isEqualTo(request.pollOption().commentActive()), + () -> assertThat(updatedPost.getCloseOption().getCloseType()).isEqualTo(CloseType.SELF) + ); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ • - κ²Œμ‹œκΈ€ μž‘μ„±μžκ°€ 아닐 경우") + void update_notPostAuthor() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + User anotherUser = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + UpdatePostRequest request = new UpdatePostRequest( + "Updated Title", + "Updated Description", + List.of( + new PollChoiceRequestDto("title1", "http://image1.com"), + new PollChoiceRequestDto("title2", "http://image2.com") + ), + new PollOptionDto(Scope.PRIVATE, PollType.MULTIPLE, CommentActive.CLOSED), + new CloseOptionDto(CloseType.SELF, null, null) + ); + + + //when then + assertThatThrownBy(() -> postService.update(anotherUser.getId(), post.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ • - 이미 마감된 κ²Œμ‹œκΈ€μΈ 경우") + void update_alreadyClosed() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .userId(user.getId()) + .status(Status.CLOSED) + .build() + ); + UpdatePostRequest request = new UpdatePostRequest( + "Updated Title", + "Updated Description", + List.of( + new PollChoiceRequestDto("title1", "http://image1.com"), + new PollChoiceRequestDto("title2", "http://image2.com") + ), + new PollOptionDto(Scope.PRIVATE, PollType.MULTIPLE, CommentActive.CLOSED), + new CloseOptionDto(CloseType.SELF, null, null) + ); + + + //when then + assertThatThrownBy(() -> postService.update(user.getId(), post.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ • - 제λͺ©μ΄ 50자λ₯Ό μ΄ˆκ³Όν•˜λŠ” 경우") + void update_titleLengthExceeded() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + UpdatePostRequest request = new UpdatePostRequest( + "a".repeat(51), + "Updated Description", + List.of( + new PollChoiceRequestDto("title1", "http://image1.com"), + new PollChoiceRequestDto("title2", "http://image2.com") + ), + new PollOptionDto(Scope.PRIVATE, PollType.MULTIPLE, CommentActive.CLOSED), + new CloseOptionDto(CloseType.SELF, null, null) + ); + + + //when then + assertThatThrownBy(() -> postService.update(user.getId(), post.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.TITLE_LENGTH_EXCEEDED.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ • - μ„€λͺ…이 100자λ₯Ό μ΄ˆκ³Όν•˜λŠ” 경우") + void update_descriptionLengthExceeded() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + UpdatePostRequest request = new UpdatePostRequest( + "Updated Title", + "a".repeat(101), + List.of( + new PollChoiceRequestDto("title1", "http://image1.com"), + new PollChoiceRequestDto("title2", "http://image2.com") + ), + new PollOptionDto(Scope.PRIVATE, PollType.MULTIPLE, CommentActive.CLOSED), + new CloseOptionDto(CloseType.SELF, null, null) + ); + + + //when then + assertThatThrownBy(() -> postService.update(user.getId(), post.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ • - DATE νƒ€μž… 마감 μ˜΅μ…˜μ—μ„œ κ³Όκ±° λ‚ μ§œλ‘œ μ„€μ •ν•˜λŠ” 경우") + void update_invalidPastDateCloseOption() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .userId(user.getId()) + .closeOption( + CloseOption.create(CloseType.DATE, LocalDateTime.now().plusDays(1), null) + ) + .build() + ); + + UpdatePostRequest request = new UpdatePostRequest( + "Updated Title", + "Updated Description", + List.of(), + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.DATE, LocalDateTime.now().minusDays(1), null) + ); + + //when then + assertThatThrownBy(() -> postService.update(user.getId(), post.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_DATE_CLOSE_OPTION.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ • - DATE νƒ€μž… 마감 μ˜΅μ…˜μ—μ„œ 생성 μ‹œκ°„ κΈ°μ€€ 1μ‹œκ°„ μ΄λ‚΄λ‘œ μ„€μ •ν•˜λŠ” 경우") + void update_invalidDateCloseOptionWithinOneHour() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .userId(user.getId()) + .closeOption( + CloseOption.create(CloseType.DATE, LocalDateTime.now().plusDays(1), null) + ) + .build() + ); + + UpdatePostRequest request = new UpdatePostRequest( + "Updated Title", + "Updated Description", + List.of(), + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.DATE, LocalDateTime.now().plusMinutes(30), null) + ); + + //when then + assertThatThrownBy(() -> postService.update(user.getId(), post.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_DATE_CLOSE_OPTION.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ • - VOTER νƒ€μž… 마감 μ˜΅μ…˜μ—μ„œ ν˜„μž¬ νˆ¬ν‘œμž μˆ˜λ³΄λ‹€ 적은 κ°’μœΌλ‘œ μ„€μ •ν•˜λŠ” 경우") + void update_invalidVoterCloseOption() throws Exception { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + User voter1 = userRepository.save(UserFixture.createDefaultUser()); + User voter2 = userRepository.save(UserFixture.createDefaultUser()); + + Post post = postRepository.save( + PostFixture.createPostBuilder() + .userId(user.getId()) + .closeOption( + CloseOption.create(CloseType.VOTER, null, 10) + ) + .build() + ); + + // νˆ¬ν‘œ 데이터 생성 (2λͺ…μ˜ νˆ¬ν‘œμž) + voteRepository.save(VoteFixture.createDefaultVote(voter1.getId(), post.getId(), post.getPollChoices().get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(voter2.getId(), post.getId(), post.getPollChoices().get(1).getId())); + + UpdatePostRequest request = new UpdatePostRequest( + "Updated Title", + "Updated Description", + List.of(), + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.VOTER, null, 1) // 1λͺ…μœΌλ‘œ μ„€μ • (ν˜„μž¬ 2λͺ… νˆ¬ν‘œν•¨) + ); + + //when then + assertThatThrownBy(() -> postService.update(user.getId(), post.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_VOTER_CLOSE_OPTION.getMessage()); + } + +} diff --git a/src/test/java/com/chooz/post/application/PostQueryServiceTest.java b/src/test/java/com/chooz/post/application/PostQueryServiceTest.java new file mode 100644 index 00000000..f6791a5e --- /dev/null +++ b/src/test/java/com/chooz/post/application/PostQueryServiceTest.java @@ -0,0 +1,507 @@ +package com.chooz.post.application; + +import com.chooz.comment.domain.Comment; +import com.chooz.comment.domain.CommentRepository; +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.CommentActive; +import com.chooz.post.domain.PollOption; +import com.chooz.post.domain.PollType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.post.domain.Scope; +import com.chooz.post.presentation.dto.FeedResponse; +import com.chooz.post.presentation.dto.MyPagePostResponse; +import com.chooz.post.presentation.dto.PollChoiceVoteResponse; +import com.chooz.post.presentation.dto.PostResponse; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.thumbnail.domain.ThumbnailRepository; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.application.VoteService; +import com.chooz.vote.domain.Vote; +import com.chooz.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.ArrayList; +import java.util.List; + +import static com.chooz.support.fixture.CommentFixture.createDefaultComment; +import static com.chooz.support.fixture.PostFixture.createDefaultPost; +import static com.chooz.support.fixture.PostFixture.createPostBuilder; +import static com.chooz.support.fixture.ThumbnailFixture.createDefaultThumbnail; +import static com.chooz.support.fixture.UserFixture.createDefaultUser; +import static com.chooz.support.fixture.UserFixture.createUserBuilder; +import static com.chooz.support.fixture.VoteFixture.createDefaultVote; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class PostQueryServiceTest extends IntegrationTest { + + @Autowired + PostService postService; + + @Autowired + PostRepository postRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + VoteRepository voteRepository; + + @Autowired + CommentRepository commentRepository; + + @Autowired + ThumbnailRepository thumbnailRepository; + @Autowired + private VoteService voteService; + + @Test + @DisplayName("κ²Œμ‹œκΈ€ 쑰회") + void findById() throws Exception { + //given + User user1 = userRepository.save(createDefaultUser()); + User user2 = userRepository.save(createDefaultUser()); + Post post = postRepository.save(createDefaultPost(user1.getId())); + Vote vote = voteRepository.save(VoteFixture.createDefaultVote(user1.getId(), post.getId(), post.getPollChoices().get(0).getId())); + + //when + PostResponse response = postService.findById(user1.getId(), post.getId(), "shareKey"); + + //then + List pollChoices = response.pollChoices(); + assertAll( + () -> assertThat(response.id()).isEqualTo(post.getId()), + () -> assertThat(response.description()).isEqualTo(post.getDescription()), + () -> assertThat(response.title()).isEqualTo(post.getTitle()), + () -> assertThat(response.author().nickname()).isEqualTo(user1.getNickname()), + () -> assertThat(response.author().profileUrl()).isEqualTo(user1.getProfileUrl()), + () -> assertThat(response.shareUrl()).isEqualTo(post.getShareUrl()), + () -> assertThat(response.isAuthor()).isTrue(), + () -> assertThat(response.commentCount()).isEqualTo(0L), + () -> assertThat(response.voterCount()).isEqualTo(1L), + () -> assertThat(response.pollOption()).isNotNull(), + () -> assertThat(response.closeOption()).isNotNull(), + () -> assertThat(pollChoices).hasSize(2), + () -> assertThat(pollChoices.get(0).imageUrl()).isEqualTo(post.getPollChoices().get(0).getImageUrl()), + () -> assertThat(pollChoices.get(0).voteId()).isEqualTo(vote.getId()), + () -> assertThat(pollChoices.get(1).imageUrl()).isEqualTo(post.getPollChoices().get(1).getImageUrl()), + () -> assertThat(pollChoices.get(1).voteId()).isNull() + ); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ 쑰회 - 곡개 λ²”μœ„ PUBLIC") + void findById_public() throws Exception { + //given + User author = userRepository.save(createDefaultUser()); + User otherUser = userRepository.save(createDefaultUser()); + String shareKey = "shareKey"; + String otherKey = "otherKey"; + Post post = postRepository.save(PostFixture.createPostBuilder() + .shareUrl(shareKey) + .userId(author.getId()) + .pollOption(PostFixture.pollOptionBuilder().scope(Scope.PUBLIC).build()) + .build()); + + //when then + assertThatNoException() + .isThrownBy(() -> postService.findById(author.getId(), post.getId(), shareKey)); + assertThatNoException() + .isThrownBy(() -> postService.findById(author.getId(), post.getId(), otherKey)); + assertThatNoException() + .isThrownBy(() -> postService.findById(author.getId(), post.getId(), null)); + assertThatNoException() + .isThrownBy(() -> postService.findById(otherUser.getId(), post.getId(), shareKey)); + assertThatNoException() + .isThrownBy(() -> postService.findById(otherUser.getId(), post.getId(), otherKey)); + assertThatNoException() + .isThrownBy(() -> postService.findById(otherUser.getId(), post.getId(), null)); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ 쑰회 - 곡개 λ²”μœ„ PRIVATE") + void findById_private() throws Exception { + //given + User author = userRepository.save(createDefaultUser()); + User otherUser = userRepository.save(createDefaultUser()); + String shareKey = "shareKey"; + String otherKey = "otherKey"; + Post post = postRepository.save(PostFixture.createPostBuilder() + .shareUrl(shareKey) + .userId(author.getId()) + .pollOption(PostFixture.pollOptionBuilder().scope(Scope.PRIVATE).build()) + .build()); + + //when then + assertThatNoException() + .isThrownBy(() -> postService.findById(author.getId(), post.getId(), shareKey)); + assertThatNoException() + .isThrownBy(() -> postService.findById(author.getId(), post.getId(), otherKey)); + assertThatNoException() + .isThrownBy(() -> postService.findById(author.getId(), post.getId(), null)); + assertThatNoException() + .isThrownBy(() -> postService.findById(otherUser.getId(), post.getId(), shareKey)); + assertThatThrownBy(() -> postService.findById(otherUser.getId(), post.getId(), otherKey)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_NOT_REVEALABLE.getMessage()); + assertThatThrownBy(() -> postService.findById(otherUser.getId(), post.getId(), null)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_NOT_REVEALABLE.getMessage()); + } + + @Test + @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - μ»€μ„œ null인 경우") + void findUserPosts() throws Exception { + //given + User user = userRepository.save(createDefaultUser()); + List posts = createPosts(user, 15); + int size = 10; + + //when + var response = postService.findUserPosts(user.getId(), user.getId(), null, size); + + //then + assertAll( + () -> assertThat(response.data()).hasSize(size), + () -> assertThat(response.hasNext()).isTrue(), + () -> assertThat(response.nextCursor()).isEqualTo(posts.get(posts.size() - size).getId()) + ); + } + + @Test + @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - μ»€μ„œ μžˆλŠ” 경우") + void findUserPosts2() throws Exception { + //given + User user = userRepository.save(createDefaultUser()); + List posts = createPosts(user, 15); + int size = 10; + + //when + var response = postService.findUserPosts(user.getId(), user.getId(), posts.get(3).getId(), size); + + //then + assertAll( + () -> assertThat(response.data()).hasSize(3), + () -> assertThat(response.hasNext()).isFalse(), + () -> assertThat(response.nextCursor()).isEqualTo(posts.get(0).getId()) + ); + } + + @Test + @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - 쀑볡 νˆ¬ν‘œ") + void findUserPosts_multiple() { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post1 = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(PostFixture.multiplePollOption()) + .build()); + Post post2 = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(PostFixture.multiplePollOption()) + .build()); + //μœ μ €1 κ²Œμ‹œκΈ€1 선택지 1, 2 볡수 νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post1.getId(), post1.getPollChoices().get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post1.getId(), post1.getPollChoices().get(1).getId())); + + //μœ μ €1 κ²Œμ‹œκΈ€2 선택지 1 단일 νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post2.getId(), post2.getPollChoices().get(0).getId())); + + //when + var response = postService.findUserPosts(user.getId(), user.getId(), null, 10); + + //then + List data = response.data(); + assertAll( + () -> assertThat(response.data()).hasSize(2), + () -> assertThat(response.hasNext()).isFalse(), + + () -> assertThat(data.getFirst().id()).isEqualTo(post2.getId()), + () -> assertThat(data.getFirst().title()).isEqualTo(post2.getTitle()), + + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().title()).isEqualTo(post2.getPollChoices().get(0).getTitle()), + () -> assertThat(data.getFirst().postVoteInfo().totalVoterCount()).isEqualTo(1), + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().voteCount()).isEqualTo(1), + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().voteRatio()).isEqualTo("100"), + + () -> assertThat(data.get(1).id()).isEqualTo(post1.getId()), + () -> assertThat(data.get(1).title()).isEqualTo(post1.getTitle()), + + () -> assertThat(data.get(1).postVoteInfo().mostVotedPollChoice().title()).isEqualTo(post1.getPollChoices().get(0).getTitle()), + () -> assertThat(data.get(1).postVoteInfo().totalVoterCount()).isEqualTo(2), + () -> assertThat(data.get(1).postVoteInfo().mostVotedPollChoice().voteCount()).isEqualTo(1), + () -> assertThat(data.get(1).postVoteInfo().mostVotedPollChoice().voteRatio()).isEqualTo("50") + ); + } + + @Test + @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - 쀑볡 νˆ¬ν‘œ2") + void findUserPosts_multiple2() { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + User user2 = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(PostFixture.multiplePollOption()) + .build()); + //μœ μ €1 선택지 1, 2 볡수 νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(1).getId())); + + //μœ μ €2 선택지 1 단일 νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + + //when + var response = postService.findUserPosts(user.getId(), user.getId(), null, 10); + + //then + List data = response.data(); + assertAll( + () -> assertThat(response.data()).hasSize(1), + () -> assertThat(response.hasNext()).isFalse(), + + () -> assertThat(data.getFirst().id()).isEqualTo(post.getId()), + () -> assertThat(data.getFirst().title()).isEqualTo(post.getTitle()), + + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().title()).isEqualTo(post.getPollChoices().get(0).getTitle()), + () -> assertThat(data.getFirst().postVoteInfo().totalVoterCount()).isEqualTo(3), + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().voteCount()).isEqualTo(2), + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().voteRatio()).isEqualTo("67") + ); + } + + @Test + @DisplayName("μœ μ €κ°€ νˆ¬ν‘œν•œ κ²Œμ‹œκΈ€ 쑰회 - μ»€μ„œ null인 경우") + void findVotedPosts() throws Exception { + //given + User user = userRepository.save(createDefaultUser()); + List posts = createPosts(user, 15); + for (int i = 0; i < 15; i++) { + Post post = posts.get(i); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + int size = 10; + + //when + var response = postService.findVotedPosts(user.getId(), user.getId(), null, size); + + //then + int 전체_15κ°œμ—μ„œ_맨_λ§ˆμ§€λ§‰_데이터_인덱슀 = posts.size() - size; + assertAll( + () -> assertThat(response.data()).hasSize(size), + () -> assertThat(response.hasNext()).isTrue(), + () -> assertThat(response.nextCursor()).isEqualTo(posts.get(전체_15κ°œμ—μ„œ_맨_λ§ˆμ§€λ§‰_데이터_인덱슀).getId()) + ); + } + + @Test + @DisplayName("μœ μ €κ°€ νˆ¬ν‘œν•œ κ²Œμ‹œκΈ€ 쑰회 - 쀑볡 νˆ¬ν‘œ") + void findVotedPosts_multiple() { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(PostFixture.multiplePollOption()) + .build()); + Post post2 = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(PostFixture.multiplePollOption()) + .build()); + //μœ μ €1 선택지 1, 2 볡수 νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(1).getId())); + + //μœ μ €1 κ²Œμ‹œκΈ€2 νˆ¬ν‘œ ν›„ μ·¨μ†Œ + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post2.getId(), post2.getPollChoices().get(1).getId())); + voteService.vote(user.getId(), post2.getId(), List.of()); + + //when + var response = postService.findVotedPosts(user.getId(), user.getId(), null, 10); + + //then + List data = response.data(); + assertAll( + () -> assertThat(response.data()).hasSize(1), + () -> assertThat(response.hasNext()).isFalse(), + + () -> assertThat(data.getFirst().id()).isEqualTo(post.getId()), + () -> assertThat(data.getFirst().title()).isEqualTo(post.getTitle()), + + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().title()).isEqualTo(post.getPollChoices().get(0).getTitle()), + () -> assertThat(data.getFirst().postVoteInfo().totalVoterCount()).isEqualTo(2), + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().voteCount()).isEqualTo(1), + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().voteRatio()).isEqualTo("50") + ); + } + + @Test + @DisplayName("μœ μ €κ°€ νˆ¬ν‘œν•œ κ²Œμ‹œκΈ€ 쑰회 - 쀑볡 νˆ¬ν‘œ2") + void findVotedPosts_multiple2() { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + User user2 = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(PostFixture.multiplePollOption()) + .build()); + //μœ μ €1 선택지 1, 2 볡수 νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(1).getId())); + + //μœ μ €2 선택지 1 단일 νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + + //when + var response = postService.findVotedPosts(user.getId(), user.getId(), null, 10); + + //then + List data = response.data(); + assertAll( + () -> assertThat(response.data()).hasSize(1), + () -> assertThat(response.hasNext()).isFalse(), + + () -> assertThat(data.getFirst().id()).isEqualTo(post.getId()), + () -> assertThat(data.getFirst().title()).isEqualTo(post.getTitle()), + + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().title()).isEqualTo(post.getPollChoices().get(0).getTitle()), + () -> assertThat(data.getFirst().postVoteInfo().totalVoterCount()).isEqualTo(3), + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().voteCount()).isEqualTo(2), + () -> assertThat(data.getFirst().postVoteInfo().mostVotedPollChoice().voteRatio()).isEqualTo("67") + ); + } + + @Test + @DisplayName("λ§ˆμ΄νŽ˜μ΄μ§€ κ²Œμ‹œκΈ€ 곡개 λ²”μœ„ - 본인인 경우") + void scope_author() { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post publicPost = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(PostFixture.pollOptionBuilder() + .scope(Scope.PUBLIC) + .build()) + .build()); + Post privatePost = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(PostFixture.pollOptionBuilder() + .scope(Scope.PRIVATE) + .build()) + .build()); + //μœ μ €1 본인 κ²Œμ‹œκΈ€ 1 2 νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), publicPost.getId(), publicPost.getPollChoices().get(0).getId())); + + //when + var response1 = postService.findVotedPosts(user.getId(), user.getId(), null, 10); + var response2 = postService.findUserPosts(user.getId(), user.getId(), null, 10); + + //then + assertThat(response1.data()).hasSize(1); + assertThat(response2.data()).hasSize(2); + } + + @Test + @DisplayName("λ§ˆμ΄νŽ˜μ΄μ§€ κ²Œμ‹œκΈ€ 곡개 λ²”μœ„ - λ‹€λ₯Έ μ‚¬λžŒμΈ 경우") + void scope_otherUser() { + //given + User author = userRepository.save(UserFixture.createDefaultUser()); + User user = userRepository.save(UserFixture.createDefaultUser()); + Post publicPost = postRepository.save(PostFixture.createPostBuilder() + .userId(author.getId()) + .pollOption(PostFixture.pollOptionBuilder() + .scope(Scope.PUBLIC) + .build()) + .build()); + Post privatePost = postRepository.save(PostFixture.createPostBuilder() + .userId(author.getId()) + .pollOption(PostFixture.pollOptionBuilder() + .scope(Scope.PRIVATE) + .build()) + .build()); + //μœ μ €1 본인 κ²Œμ‹œκΈ€ 1 2 νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(author.getId(), privatePost.getId(), privatePost.getPollChoices().get(0).getId())); + + //when + var response1 = postService.findVotedPosts(user.getId(), author.getId(), null, 10); + var response2 = postService.findUserPosts(user.getId(), author.getId(), null, 10); + + //then + assertThat(response1.data()).hasSize(0); + assertThat(response2.data()).hasSize(1); + } + + @Test + @DisplayName("ν”Όλ“œ 쑰회 - λ‚΄ κ²Œμ‹œκΈ€ 1개, 곡개 κ²Œμ‹œκΈ€ 15개, νˆ¬ν‘œ 10개, λŒ“κΈ€ 20개") + void findFeed() throws Exception { + //given + int size = 20; + User user1 = userRepository.save(createUserBuilder().build()); + User user2 = userRepository.save(createUserBuilder().build()); + + List publicPosts = createPostsWithScope(user2, Scope.PUBLIC, 15); + createPostsWithScope(user2, Scope.PRIVATE, 3); + Post myPost = postRepository.save(createPostBuilder().userId(user1.getId()).build()); + + createVotes(user1, publicPosts.getFirst(), 10); + createComments(user1, publicPosts.getFirst(), 20); + + List publicPostVotes = voteRepository.findByPostIdAndDeletedFalse(publicPosts.getFirst().getId()); + List publicPostComments = commentRepository.findByPostIdAndDeletedFalse(publicPosts.getFirst().getId()); + + //when + CursorBasePaginatedResponse response = postService.findFeed(user1.getId(), null, size); + + //then + assertAll( + () -> assertThat(response.data().size()).isEqualTo(16), + () -> assertThat(response.data().getLast().voterCount()).isEqualTo(1), + () -> assertThat(response.data().getLast().commentCount()).isEqualTo(publicPostComments.size()), + () -> assertThat(response.data().getLast().isAuthor()).isFalse(), + () -> assertThat(response.data().getFirst().isAuthor()).isTrue() + ); + } + + private List createPosts(User user, int size) { + List posts = new ArrayList<>(); + for (int i = 0; i < size; i++) { + Post post = postRepository.save(createDefaultPost(user.getId())); + posts.add(post); + thumbnailRepository.save(createDefaultThumbnail(post.getId(), post.getPollChoices().get(0).getId())); + } + return posts; + } + + private List createPostsWithScope(User user, Scope scope, int size) { + List posts = new ArrayList<>(); + for (int i = 0; i < size; i++) { + Post post = createPostBuilder() + .userId(user.getId()) + .pollOption(PollOption.create(PollType.SINGLE, scope, CommentActive.OPEN)) + .build(); + posts.add(postRepository.save(post)); + } + return posts; + } + + private void createVotes(User user, Post post, int size) { + for (int i = 0; i < size; i++) { + voteRepository.save(createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + } + + private void createComments(User user, Post post, int size) { + for (int i = 0; i < size; i++) { + commentRepository.save(createDefaultComment(user.getId(), post.getId())); + } + } +} diff --git a/src/test/java/com/chooz/post/application/PostVotedEventListenerTest.java b/src/test/java/com/chooz/post/application/PostVotedEventListenerTest.java new file mode 100644 index 00000000..bc4b4f3d --- /dev/null +++ b/src/test/java/com/chooz/post/application/PostVotedEventListenerTest.java @@ -0,0 +1,79 @@ +package com.chooz.post.application; + +import com.chooz.post.domain.*; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.vote.application.VotedEvent; +import com.chooz.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PostVotedEventListenerTest extends IntegrationTest { + + @Autowired + PostVotedEventListener postVotedEventListener; + + @Autowired + PostRepository postRepository; + + @Autowired + VoteRepository voteRepository; + + @Test + @DisplayName("CloseType이 VOTER이고 μ΅œλŒ€ νˆ¬ν‘œμž μˆ˜μ— λ„λ‹¬ν•˜λ©΄ κ²Œμ‹œκΈ€μ΄ λ§ˆκ°λœλ‹€") + void handle_closeTypeVoterAndReachMaxVoterCount() { + // given + Long userId = 1L; + Post post = postRepository.save( + PostFixture.createPostBuilder() + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.VOTER) + .maxVoterCount(1) + .build() + ) + .build() + ); + Long pollChoiceId = post.getPollChoices().getFirst().getId(); + voteRepository.save(VoteFixture.createDefaultVote(userId, post.getId(), pollChoiceId)); + + // when + postVotedEventListener.handle(new VotedEvent(post.getId(), List.of(pollChoiceId), userId)); + + // then + Post updatedPost = postRepository.findById(post.getId()).orElseThrow(); + assertThat(updatedPost.getStatus()).isEqualTo(Status.CLOSED); + } + + @Test + @DisplayName("CloseType이 VOTERμ΄μ§€λ§Œ μ΅œλŒ€ νˆ¬ν‘œμž μˆ˜μ— λ„λ‹¬ν•˜μ§€ μ•ŠμœΌλ©΄ κ²Œμ‹œκΈ€μ΄ λ§ˆκ°λ˜μ§€ μ•ŠλŠ”λ‹€") + void handle_closeTypeVoterButNotReachMaxVoterCount() { + // given + Long userId = 1L; + Post post = postRepository.save( + PostFixture.createPostBuilder() + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.VOTER) + .maxVoterCount(5) + .build() + ) + .build() + ); + Long pollChoiceId = post.getPollChoices().getFirst().getId(); + voteRepository.save(VoteFixture.createDefaultVote(userId, post.getId(), pollChoiceId)); + + // when + postVotedEventListener.handle(new VotedEvent(post.getId(), List.of(1L), userId)); + + // then + Post updatedPost = postRepository.findById(post.getId()).orElseThrow(); + assertThat(updatedPost.getStatus()).isEqualTo(Status.PROGRESS); + } +} diff --git a/src/test/java/com/chooz/post/application/ShareUrlKeyGeneratorTest.java b/src/test/java/com/chooz/post/application/ShareUrlKeyGeneratorTest.java new file mode 100644 index 00000000..e306c9ab --- /dev/null +++ b/src/test/java/com/chooz/post/application/ShareUrlKeyGeneratorTest.java @@ -0,0 +1,33 @@ +package com.chooz.post.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class ShareUrlKeyGeneratorTest { + + ShareUrlKeyGenerator shareUrlKeyGenerator; + + @BeforeEach + void setUp() { + shareUrlKeyGenerator = new ShareUrlKeyGenerator(Clock.systemDefaultZone()); + } + + @Test + @DisplayName("ν‚€ 생성") + void createKey() throws Exception { + //given + + //when + String key = shareUrlKeyGenerator.generateKey(); + + //then + System.out.println("key = " + key); + assertThat(key).isNotNull(); + } +} diff --git a/src/test/java/com/chooz/post/application/ShareUrlServiceTest.java b/src/test/java/com/chooz/post/application/ShareUrlServiceTest.java new file mode 100644 index 00000000..bbf72e8d --- /dev/null +++ b/src/test/java/com/chooz/post/application/ShareUrlServiceTest.java @@ -0,0 +1,37 @@ +package com.chooz.post.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class ShareUrlServiceTest { + + ShareUrlService shareUrlService; + + ShareUrlKeyGenerator shareUrlKeyGenerator; + + @BeforeEach + void setUp() { + shareUrlKeyGenerator = mock(ShareUrlKeyGenerator.class); + shareUrlService = new ShareUrlService(shareUrlKeyGenerator); + } + + @Test + @DisplayName("곡유 url 생성 및 ν‚€ 쑰회") + void generateShareUrl() throws Exception { + //given + String shareUrlKey = "174822695935299"; + given(shareUrlKeyGenerator.generateKey()) + .willReturn(shareUrlKey); + + //when then + String shareUrl = shareUrlService.generateShareUrl(); + System.out.println("shareUrl = " + shareUrl); + System.out.println("shareUrl.length() = " + shareUrl.length()); + assertThat(shareUrl).isNotNull(); + } +} diff --git a/src/test/java/com/chooz/post/domain/CloseOptionTest.java b/src/test/java/com/chooz/post/domain/CloseOptionTest.java new file mode 100644 index 00000000..0b7c4b8d --- /dev/null +++ b/src/test/java/com/chooz/post/domain/CloseOptionTest.java @@ -0,0 +1,43 @@ +package com.chooz.post.domain; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static com.chooz.post.domain.CloseType.DATE; +import static com.chooz.post.domain.CloseType.SELF; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class CloseOptionTest { + + @Test + @DisplayName("마감 μ˜΅μ…˜ 생성") + void create() throws Exception { + assertDoesNotThrow(() -> CloseOption.create(SELF, null, null)); + assertDoesNotThrow(() -> CloseOption.create(CloseType.DATE, LocalDateTime.now().plusDays(1), null)); + assertDoesNotThrow(() -> CloseOption.create(CloseType.VOTER, null, 5)); + } + + @Test + @DisplayName("μ‹œκ°„ 마감 μ˜΅μ…˜ 생성 μ‹€νŒ¨ - λ§ˆκ°μ‹œκ°„μ΄ 1μ‹œκ°„ 이내인 경우") + void createDateCloseOptionException() throws Exception { + assertThatThrownBy(() -> CloseOption.create(DATE, LocalDateTime.now().plusMinutes(59), null)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_DATE_CLOSE_OPTION.getMessage()); + } + + @Test + @DisplayName("νˆ¬ν‘œμž 마감 μ˜΅μ…˜ 생성 μ‹€νŒ¨ - νˆ¬ν‘œμž μˆ˜κ°€ 1 미만 λ˜λŠ” 999 초과인 경우") + void createVoterCloseOptionException() throws Exception { + assertThatThrownBy(() -> CloseOption.create(CloseType.VOTER, null, 0)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_VOTER_CLOSE_OPTION.getMessage()); + assertThatThrownBy(() -> CloseOption.create(CloseType.VOTER, null, 1000)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_VOTER_CLOSE_OPTION.getMessage()); + } +} diff --git a/src/test/java/com/chooz/post/domain/PollChoiceRepositoryTest.java b/src/test/java/com/chooz/post/domain/PollChoiceRepositoryTest.java new file mode 100644 index 00000000..46102899 --- /dev/null +++ b/src/test/java/com/chooz/post/domain/PollChoiceRepositoryTest.java @@ -0,0 +1,74 @@ +package com.chooz.post.domain; + +import com.chooz.post.application.dto.PollChoiceVoteInfo; +import com.chooz.support.RepositoryTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.domain.VoteRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class PollChoiceRepositoryTest extends RepositoryTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + PollChoiceRepository pollChoiceRepository; + + @Autowired + VoteRepository voteRepository; + + @Test + void findPollChoiceWithVoteInfo() throws Exception { + // given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + User user2 = userRepository.save(UserFixture.createDefaultUser()); + User user3 = userRepository.save(UserFixture.createDefaultUser()); + + Post post1 = postRepository.save(PostFixture.createDefaultPost(user1.getId())); + List post1Choices = post1.getPollChoices(); + PollChoice post1Choice1 = post1Choices.get(0); + PollChoice post1Choice2 = post1Choices.get(1); + + Post post2 = postRepository.save(PostFixture.createDefaultPost(user2.getId())); + List post2Choices = post2.getPollChoices(); + PollChoice post2Choice1 = post2Choices.get(0); + PollChoice post2Choice2 = post2Choices.get(1); + + // 첫 번째 κ²Œμ‹œκΈ€μ— νˆ¬ν‘œ 생성 - 선택지1: 2ν‘œ, 선택지2: 1ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user1.getId(), post1.getId(), post1Choice1.getId())); + voteRepository.save(VoteFixture.createDefaultVote(user2.getId(), post1.getId(), post1Choice1.getId())); + voteRepository.save(VoteFixture.createDefaultVote(user3.getId(), post1.getId(), post1Choice2.getId())); + + // 두 번째 κ²Œμ‹œκΈ€μ— νˆ¬ν‘œ 생성 - 선택지1: 0ν‘œ, 선택지2: 3ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user1.getId(), post2.getId(), post2Choice2.getId())); + voteRepository.save(VoteFixture.createDefaultVote(user2.getId(), post2.getId(), post2Choice2.getId())); + voteRepository.save(VoteFixture.createDefaultVote(user3.getId(), post2.getId(), post2Choice2.getId())); + + // when + List voteInfos = pollChoiceRepository.findPollChoiceWithVoteInfo(Arrays.asList(post1.getId(), post2.getId())); + + // then + assertThat(voteInfos).hasSize(4); + assertThat(voteInfos).extracting("postId", "pollChoiceId", "voteCounts", "title") + .containsExactlyInAnyOrder( + tuple(post1.getId(), post1Choice1.getId(), 2L, post1Choice1.getTitle()), + tuple(post1.getId(), post1Choice2.getId(), 1L, post1Choice2.getTitle()), + tuple(post2.getId(), post2Choice1.getId(), 0L, post2Choice1.getTitle()), + tuple(post2.getId(), post2Choice2.getId(), 3L, post2Choice2.getTitle()) + ); + } +} diff --git a/src/test/java/com/chooz/post/domain/PollChoiceTest.java b/src/test/java/com/chooz/post/domain/PollChoiceTest.java new file mode 100644 index 00000000..38f3e543 --- /dev/null +++ b/src/test/java/com/chooz/post/domain/PollChoiceTest.java @@ -0,0 +1,55 @@ +package com.chooz.post.domain; + +import com.chooz.common.exception.BadRequestException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class PollChoiceTest { + + @Test + @DisplayName("κ²Œμ‹œκΈ€ 이미지 생성") + void create() throws Exception { + //given + String title = "title"; + String imageUrl = "https://example.com/image.jpg"; + + //when + PollChoice pollChoice = PollChoice.create(title, imageUrl); + + //then + assertAll( + () -> assertThat(pollChoice.getTitle()).isEqualTo(title), + () -> assertThat(pollChoice.getImageUrl()).isEqualTo(imageUrl) + ); + } + + @Test + @DisplayName("νˆ¬ν‘œ 선택지 제λͺ©μ€ 10자λ₯Ό μ΄ˆκ³Όν•  수 μ—†λ‹€") + void createWithTitleExceedingMaxLength() throws Exception { + //given + String title = "12345678901"; // 11자 + String imageUrl = "https://example.com/image.jpg"; + + //when & then + assertThrows(BadRequestException.class, () -> { + PollChoice.create(title, imageUrl); + }); + } + + @Test + @DisplayName("νˆ¬ν‘œ 선택지 제λͺ©μ€ 10자일 λ•Œ 정상 μƒμ„±λœλ‹€") + void createWithTitleMaxLength() throws Exception { + //given + String title = "1234567890"; // 10자 + String imageUrl = "https://example.com/image.jpg"; + + //when + PollChoice pollChoice = PollChoice.create(title, imageUrl); + + //then + assertThat(pollChoice.getTitle()).isEqualTo(title); + } +} diff --git a/src/test/java/com/chooz/post/domain/PostRepositoryTest.java b/src/test/java/com/chooz/post/domain/PostRepositoryTest.java new file mode 100644 index 00000000..580b17f3 --- /dev/null +++ b/src/test/java/com/chooz/post/domain/PostRepositoryTest.java @@ -0,0 +1,302 @@ +package com.chooz.post.domain; + +import com.chooz.post.application.dto.PostWithVoteCount; +import com.chooz.post.application.dto.FeedDto; +import com.chooz.support.RepositoryTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static com.chooz.support.fixture.PostFixture.createDefaultPost; +import static com.chooz.support.fixture.PostFixture.createPostBuilder; +import static com.chooz.support.fixture.UserFixture.createDefaultUser; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.junit.jupiter.api.Assertions.*; + +class PostRepositoryTest extends RepositoryTest { + + @Autowired + PostRepository postRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + VoteRepository voteRepository; + + @Test + @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - κ²Œμ‹œκΈ€μ΄ 15개일 경우 15λ²ˆμ¨°λΆ€ν„° 10개 μ‘°νšŒν•΄μ•Ό 함") + void findAllByUserId1() throws Exception { + //given + long userId = 1L; + List posts = createPosts(userId, 15); + int size = 10; + + //when + Slice res = postRepository.findAllByUserId(userId, null, PageRequest.ofSize(size)); + + //then + assertAll( + () -> assertThat(res.getContent().size()).isEqualTo(size), + () -> assertThat(res.getContent().get(0).getId()).isEqualTo(posts.get(posts.size() - 1).getId()), + () -> assertThat(res.getContent().get(1).getId()).isEqualTo(posts.get(posts.size() - 2).getId()), + () -> assertThat(res.getContent().get(2).getId()).isEqualTo(posts.get(posts.size() - 3).getId()), + () -> assertThat(res.hasNext()).isTrue() + ); + } + + @Test + @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - 15개 쀑에 μ»€μ„œκ°€ 5번째 κ²Œμ‹œκΈ€μ˜ idλ©΄ 4λ²ˆμ§ΈλΆ€ν„° 0λ²ˆμ§ΈκΉŒμ§€ μ‘°νšŒν•΄μ•Ό 함") + void findAllByUserId2() throws Exception { + //given + long userId = 1L; + List posts = createPosts(userId, 15); + int size = 10; + int cursorIndex = 5; + + //when + Slice res = postRepository.findAllByUserId(userId, posts.get(cursorIndex).getId(), PageRequest.ofSize(size)); + + //then + assertAll( + () -> assertThat(res.getContent().size()).isEqualTo(5), + () -> assertThat(res.getContent().get(0).getId()).isEqualTo(posts.get(cursorIndex - 1).getId()), + () -> assertThat(res.getContent().get(1).getId()).isEqualTo(posts.get(cursorIndex - 2).getId()), + () -> assertThat(res.getContent().get(2).getId()).isEqualTo(posts.get(cursorIndex - 3).getId()), + () -> assertThat(res.hasNext()).isFalse() + ); + } + + @Test + @DisplayName("ν”Όλ“œ 쑰회") + void findByScopeAndDeletedFalse() { + //given + User user1 = userRepository.save(createDefaultUser()); + User user2 = userRepository.save(createDefaultUser()); + List myPosts = createPosts(user1.getId(), 5); + List privatePosts = createPostsWithScope(user2, Scope.PRIVATE, 5); + List publicPosts = createPostsWithScope(user2, Scope.PUBLIC, 5); + int size = 10; + + //when + Slice res = postRepository.findFeed(null, PageRequest.ofSize(size)); + + //then + assertAll( + () -> assertThat(res.getContent().size()).isEqualTo(size), + () -> assertThat(res.hasNext()).isFalse() + ); + } + + @Test + @DisplayName("마감 μ‹œκ°„ μ§€λ‚œ μ§„ν–‰ 쀑인 κ²Œμ‹œκΈ€ 쑰회") + void findPostNeedToClose() throws Exception { + //given + User user = userRepository.save(createDefaultUser()); + createPosts(user.getId(), 5); + int expected = 10; + for (int i = 0; i < expected; i++) { + Post post = createPostBuilder() + .userId(user.getId()) + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.DATE) + .closedAt(LocalDateTime.now().minusMinutes(i)) + .build()) + .build(); + postRepository.save(post); + } + + //when + List postsNeedToClose = postRepository.findPostsNeedToClose(); + + //then + assertThat(postsNeedToClose).hasSize(expected); + } + + @Test + void findPostsWithVoteCountByUserId() throws Exception { + // given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + User user2 = userRepository.save(UserFixture.createDefaultUser()); + User user3 = userRepository.save(UserFixture.createDefaultUser()); + + // user1 κ²Œμ‹œκΈ€ 생성 + Post post1 = postRepository.save(PostFixture.createDefaultPost(user1.getId())); + Post post2 = postRepository.save(PostFixture.createDefaultPost(user1.getId())); + Post post3 = postRepository.save(PostFixture.createDefaultPost(user1.getId())); + + // λ‹€λ₯Έ μ‚¬μš©μž(user2) κ²Œμ‹œκΈ€ 생성 + Post post4 = postRepository.save(PostFixture.createDefaultPost(user2.getId())); + + List post1Choices = post1.getPollChoices(); + List post2Choices = post2.getPollChoices(); + + // post1: user1, user2, user3 -> 총 3λͺ… νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user1.getId(), post1.getId(), post1Choices.get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user2.getId(), post1.getId(), post1Choices.get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user3.getId(), post1.getId(), post1Choices.get(1).getId())); + + // post2: user2 -> 총 1λͺ… νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user2.getId(), post2.getId(), post2Choices.get(0).getId())); + + // post3μ—λŠ” νˆ¬ν‘œ μ—†μŒ + + // post4: user1, user3 -> 총 2λͺ… νˆ¬ν‘œ (user1의 κ²Œμ‹œκΈ€μ΄ μ•„λ‹˜) + List post4Choices = post4.getPollChoices(); + voteRepository.save(VoteFixture.createDefaultVote(user1.getId(), post4.getId(), post4Choices.get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user3.getId(), post4.getId(), post4Choices.get(0).getId())); + + // when + Slice result = postRepository.findPostsWithVoteCountByUserId( + user1.getId(), + user1.getId(), + null, + PageRequest.of(0, 10) + ); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent()).extracting("post.id", "voteCount") + .containsExactly( + tuple(post3.getId(), 0L), + tuple(post2.getId(), 1L), + tuple(post1.getId(), 3L) + ); + } + + @Test + void findVotedPostsWithVoteCount() throws Exception { + // given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + User user2 = userRepository.save(UserFixture.createDefaultUser()); + User user3 = userRepository.save(UserFixture.createDefaultUser()); + + // user2 κ²Œμ‹œκΈ€ 생성 + Post post1 = postRepository.save(PostFixture.createDefaultPost(user2.getId())); + Post post2 = postRepository.save(PostFixture.createDefaultPost(user2.getId())); + Post post3 = postRepository.save(PostFixture.createDefaultPost(user2.getId())); + Post post4 = postRepository.save(PostFixture.createDefaultPost(user2.getId())); + + List post1Choices = post1.getPollChoices(); + List post2Choices = post2.getPollChoices(); + List post3Choices = post3.getPollChoices(); + List post4Choices = post4.getPollChoices(); + + // post1: user1, user2 -> 총 2λͺ… νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user1.getId(), post1.getId(), post1Choices.get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user2.getId(), post1.getId(), post1Choices.get(1).getId())); + + // post2: user1, user3 -> 총 2λͺ… νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user1.getId(), post2.getId(), post2Choices.get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user3.getId(), post2.getId(), post2Choices.get(1).getId())); + + // post3: user2, user3 -> 총 2λͺ… νˆ¬ν‘œ (user1은 νˆ¬ν‘œν•˜μ§€ μ•ŠμŒ) + voteRepository.save(VoteFixture.createDefaultVote(user2.getId(), post3.getId(), post3Choices.get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user3.getId(), post3.getId(), post3Choices.get(1).getId())); + + // post4: user1, user2, user3 -> 총 3λͺ… νˆ¬ν‘œ + voteRepository.save(VoteFixture.createDefaultVote(user1.getId(), post4.getId(), post4Choices.get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user2.getId(), post4.getId(), post4Choices.get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user3.getId(), post4.getId(), post4Choices.get(1).getId())); + + // when + Slice result = postRepository.findVotedPostsWithVoteCount( + user1.getId(), + user1.getId(), + null, + PageRequest.of(0, 10) + ); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent()).extracting("post.id", "voteCount") + .containsExactly( + tuple(post4.getId(), 3L), + tuple(post2.getId(), 2L), + tuple(post1.getId(), 2L) + ); + } + + @Test + @DisplayName("본인의 λ§ˆμ΄νŽ˜μ΄μ§€λ₯Ό λ³΄λŠ” 경우, hasNextκ°€ trueμ—¬μ•Ό 함") + void findPostsWithVoteCountByUserId_hasNext_true() throws Exception { + // given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + + // λΉ„κ³΅κ°œ 3개, 곡개 9개 + createPostsWithScope(user1, Scope.PRIVATE, 3); + List publicPosts = createPostsWithScope(user1, Scope.PUBLIC, 9); + + voteRepository.save(VoteFixture.createDefaultVote(user1.getId(), publicPosts.getFirst().getId(), publicPosts.getFirst().getPollChoices().getFirst().getId())); + + // when + Slice result = postRepository.findPostsWithVoteCountByUserId( + user1.getId(), + user1.getId(), + null, + PageRequest.of(0, 10) + ); + + // then + assertThat(result.getContent()).hasSize(10); + assertThat(result.hasNext()).isTrue(); + } + + @Test + @DisplayName("λ‹€λ₯Έ μ‚¬λžŒμ˜ λ§ˆμ΄νŽ˜μ΄μ§€λ₯Ό λ³΄λŠ” 경우, hasNextκ°€ falseμ—¬μ•Ό 함") + void findPostsWithVoteCountByUserId_hasNext_false() throws Exception { + // given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + User user2 = userRepository.save(UserFixture.createDefaultUser()); + + // λΉ„κ³΅κ°œ 3개, 곡개 9개 + createPostsWithScope(user1, Scope.PRIVATE, 3); + createPostsWithScope(user1, Scope.PUBLIC, 9); + + // when + Slice result = postRepository.findPostsWithVoteCountByUserId( + user2.getId(), + user1.getId(), + null, + PageRequest.of(0, 10) + ); + + // then + assertThat(result.getContent()).hasSize(9); + assertThat(result.hasNext()).isFalse(); + } + + private List createPosts(long userId, int size) { + List posts = new ArrayList<>(); + for (int i = 0; i < size; i++) { + posts.add(postRepository.save(createDefaultPost(userId))); + } + return posts; + } + + private List createPostsWithScope(User user, Scope scope, int size) { + List posts = new ArrayList<>(); + for (int i = 0; i < size; i ++) { + Post post = createPostBuilder() + .userId(user.getId()) + .pollOption(PollOption.create(PollType.SINGLE, scope, CommentActive.OPEN)) + .build(); + posts.add(postRepository.save(post)); + } + return posts; + } +} diff --git a/src/test/java/com/chooz/post/domain/PostTest.java b/src/test/java/com/chooz/post/domain/PostTest.java new file mode 100644 index 00000000..692aef86 --- /dev/null +++ b/src/test/java/com/chooz/post/domain/PostTest.java @@ -0,0 +1,230 @@ +package com.chooz.post.domain; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.support.fixture.PostFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static com.chooz.support.fixture.PostFixture.createDefaultPost; +import static com.chooz.support.fixture.PostFixture.createPostBuilder; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class PostTest { + + @Test + @DisplayName("κ²Œμ‹œκΈ€ 생성") + void create() throws Exception { + //given + long userId = 1L; + String title = "title"; + String description = "description"; + String imageUrl = "http://example.com/image1"; + List pollChoices = List.of( + PollChoice.create("title1", "http://example.com/image1"), + PollChoice.create("title2", "http://example.com/image2") + ); + + //when + Post post = Post.create( + userId, + title, + description, + imageUrl, + pollChoices, + "http://example.com/shareurl", + PollOption.create(PollType.SINGLE, Scope.PUBLIC, CommentActive.OPEN), + CloseOption.create(CloseType.SELF, null, null) + ); + + //then + assertAll( + () -> assertThat(post.getUserId()).isEqualTo(userId), + () -> assertThat(post.getDescription()).isEqualTo(description), + () -> assertThat(post.getStatus()).isEqualTo(Status.PROGRESS), + () -> assertThat(post.getPollChoices()).hasSize(2), + () -> assertThat(post.getShareUrl()).isEqualTo("http://example.com/shareurl"), + () -> assertThat(post.getImageUrl()).isEqualTo(imageUrl), + () -> assertThat(post.getPollOption().getPollType()).isEqualTo(PollType.SINGLE), + () -> assertThat(post.getPollOption().getScope()).isEqualTo(Scope.PUBLIC), + () -> assertThat(post.getCloseOption().getCloseType()).isEqualTo(CloseType.SELF), + () -> assertThat(post.getCloseOption().getClosedAt()).isNull(), + () -> assertThat(post.getCloseOption().getMaxVoterCount()).isNull(), + () -> assertThat(post.getPollChoices().get(0).getTitle()).isEqualTo("title1"), + () -> assertThat(post.getPollChoices().get(0).getImageUrl()).isEqualTo("http://example.com/image1"), + () -> assertThat(post.getPollChoices().get(1).getTitle()).isEqualTo("title2"), + () -> assertThat(post.getPollChoices().get(1).getImageUrl()).isEqualTo("http://example.com/image2") + ); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ 생성 - 이미지가 2개 미만인 경우") + void create_invalidPollChoiceCount() throws Exception { + //given + List pollChoices = List.of( + PollChoice.create("title1", "http://example.com/image1") + ); + + //when then + assertThatThrownBy(() -> createPostBuilder().pollChoices(pollChoices).build()) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_POLL_CHOICE_COUNT.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ 생성 - 이미지가 10개 초과인 경우") + void create_invalidPollChoiceCount2() throws Exception { + //given + List pollChoices = new ArrayList<>(); + for (int i = 0; i <= 10; i++) { + pollChoices.add(PollChoice.create("title" + i, "http://example.com/image" + i)); + } + + //when then + assertThatThrownBy(() -> createPostBuilder().pollChoices(pollChoices).build()) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_POLL_CHOICE_COUNT.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ 생성 - μ„€λͺ…이 50자 λ„˜μ–΄κ°€λŠ” 경우") + void create_titleCountExceeded() throws Exception { + //given + String title = "a".repeat(51); + + //when then + assertThatThrownBy(() -> createPostBuilder().title(title).build()) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.TITLE_LENGTH_EXCEEDED.getMessage()); + } + + @Test + @DisplayName("κ²Œμ‹œκΈ€ 생성 - μ„€λͺ…이 100자 λ„˜μ–΄κ°€λŠ” 경우") + void create_descriptionCountExceeded() throws Exception { + //given + String description = "a".repeat(101); + + //when then + assertThatThrownBy(() -> createPostBuilder().description(description).build()) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED.getMessage()); + } + + @Test + @DisplayName("νˆ¬ν‘œ 마감") + void closeByAuthor() throws Exception { + //given + long userId = 1L; + Post post = createDefaultPost(userId); + + //when + post.closeByAuthor(userId); + + //then + assertThat(post.getStatus()).isEqualTo(Status.CLOSED); + } + + @Test + @DisplayName("νˆ¬ν‘œ 마감 - 이미 마감된 κ²Œμ‹œκΈ€μΈ 경우") + void closeByAuthor_alreadyClosed() throws Exception { + //given + long userId = 1L; + Post post = createPostBuilder() + .userId(userId) + .status(Status.CLOSED) + .build(); + + //when then + assertThatThrownBy(() -> post.closeByAuthor(userId)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + } + + @Test + @DisplayName("νˆ¬ν‘œ 마감 - κ²Œμ‹œκΈ€ μž‘μ„±μžκ°€ μ•„λ‹Œ 경우") + void closeByAuthor_notPostAuthor() throws Exception { + //given + long userId = 1L; + Post post = createPostBuilder() + .userId(userId) + .build(); + + //when then + assertThatThrownBy(() -> post.closeByAuthor(2L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); + } + + @Test + @DisplayName("νˆ¬ν‘œ 마감 - 마감 방식이 SELFκ°€ μ•„λ‹Œ 경우") + void closeByAuthor_onlySelfCanClose() throws Exception { + //given + long userId = 1L; + Post post = createPostBuilder() + .closeOption(PostFixture.voterCloseOption(5)) + .userId(userId) + .build(); + + //when then + assertThatThrownBy(() -> post.closeByAuthor(userId)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.ONLY_SELF_CAN_CLOSE.getMessage()); + } + + @Test + @DisplayName("곡개 λ²”μœ„ - public") + void isRevealable_public() throws Exception { + // given + long author = 1L; + long otherUser = 2L; + String shareKey = "key"; + Post post = createPostBuilder() + .userId(author) + .shareUrl(shareKey) + .pollOption(PostFixture.pollOptionBuilder().scope(Scope.PUBLIC).build()) + .build(); + + // when + boolean res1 = post.isRevealable(author, shareKey); //본인, ν‚€ κ°™μŒ + boolean res2 = post.isRevealable(author, shareKey + 1); //본인, ν‚€ 닀름 + boolean res3 = post.isRevealable(otherUser, shareKey); //λ‹€λ₯Έ μœ μ €, ν‚€ κ°™μŒ + boolean res4 = post.isRevealable(otherUser, shareKey + 1); //λ‹€λ₯Έ μœ μ €, ν‚€ 닀름 + + // then + assertThat(res1).isTrue(); + assertThat(res2).isTrue(); + assertThat(res3).isTrue(); + assertThat(res4).isTrue(); + } + + @Test + @DisplayName("곡개 λ²”μœ„ - private") + void isRevealable_private() throws Exception { + // given + long author = 1L; + long otherUser = 2L; + String shareKey = "key"; + Post post = createPostBuilder() + .userId(author) + .shareUrl(shareKey) + .pollOption(PostFixture.pollOptionBuilder().scope(Scope.PRIVATE).build()) + .build(); + + // when + boolean res1 = post.isRevealable(author, shareKey); //본인, ν‚€ κ°™μŒ + boolean res2 = post.isRevealable(author, shareKey + 1); //본인, ν‚€ 닀름 + boolean res3 = post.isRevealable(otherUser, shareKey); //λ‹€λ₯Έ μœ μ €, ν‚€ κ°™μŒ + boolean res4 = post.isRevealable(otherUser, shareKey + 1); //λ‹€λ₯Έ μœ μ €, ν‚€ 닀름 + + // then + assertThat(res1).isTrue(); + assertThat(res2).isTrue(); + assertThat(res3).isTrue(); + assertThat(res4).isFalse(); + } +} diff --git a/src/test/java/com/chooz/post/presentation/PostControllerTest.java b/src/test/java/com/chooz/post/presentation/PostControllerTest.java new file mode 100644 index 00000000..7f890776 --- /dev/null +++ b/src/test/java/com/chooz/post/presentation/PostControllerTest.java @@ -0,0 +1,699 @@ +package com.chooz.post.presentation; + +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.CommentActive; +import com.chooz.post.domain.PollType; +import com.chooz.post.domain.Scope; +import com.chooz.post.domain.Status; +import com.chooz.post.presentation.dto.AuthorDto; +import com.chooz.post.presentation.dto.CloseOptionDto; +import com.chooz.post.presentation.dto.CreatePostRequest; +import com.chooz.post.presentation.dto.CreatePostResponse; +import com.chooz.post.presentation.dto.FeedResponse; +import com.chooz.post.presentation.dto.MostVotedPollChoiceDto; +import com.chooz.post.presentation.dto.MyPagePostResponse; +import com.chooz.post.presentation.dto.PollChoiceRequestDto; +import com.chooz.post.presentation.dto.PollChoiceResponse; +import com.chooz.post.presentation.dto.PollChoiceVoteResponse; +import com.chooz.post.presentation.dto.PollOptionDto; +import com.chooz.post.presentation.dto.PostResponse; +import com.chooz.post.presentation.dto.UpdatePostRequest; +import com.chooz.post.presentation.dto.UpdatePostResponse; +import com.chooz.support.RestDocsTest; +import com.chooz.support.WithMockUserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.test.context.support.WithAnonymousUser; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +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.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PostControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("κ²Œμ‹œκΈ€ 생성") + void createPost() throws Exception { + //given + CreatePostRequest request = new CreatePostRequest( + "title", + "description", + List.of( + new PollChoiceRequestDto("title1", "http://image1.com"), + new PollChoiceRequestDto("title2", "http://image2.com") + ), + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.SELF, null, null) + ); + CreatePostResponse response = new CreatePostResponse(1L, "shareUrl"); + given(postService.create(any(), any())) + .willReturn(response); + + //when then + mockMvc.perform(post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + requestFields( + fieldWithPath("title") + .type(JsonFieldType.STRING) + .description("κ²Œμ‹œκΈ€ 제λͺ©") + .attributes(constraints("1~50자 사이")), + fieldWithPath("description") + .type(JsonFieldType.STRING) + .description("μ„€λͺ…") + .attributes(constraints("0~100자 사이")), + fieldWithPath("pollChoices") + .type(JsonFieldType.ARRAY) + .description("νˆ¬ν‘œ 선택지") + .attributes(constraints("μ΅œμ†Œ 2개 μ΅œλŒ€ 10개")), + fieldWithPath("pollChoices[].title") + .type(JsonFieldType.STRING) + .description("νˆ¬ν‘œ 선택지 제λͺ©"), + fieldWithPath("pollChoices[].imageUrl") + .type(JsonFieldType.STRING) + .description("νˆ¬ν‘œ 선택지 이미지 url"), + fieldWithPath("pollOption") + .type(JsonFieldType.OBJECT) + .description("νˆ¬ν‘œ μ˜΅μ…˜"), + fieldWithPath("pollOption.scope") + .type(JsonFieldType.STRING) + .description(enumDescription("νˆ¬ν‘œ 곡개 λ²”μœ„", Scope.class)), + fieldWithPath("pollOption.pollType") + .type(JsonFieldType.STRING) + .description(enumDescription("νˆ¬ν‘œ 방식", PollType.class)), + fieldWithPath("pollOption.commentActive") + .type(JsonFieldType.STRING) + .description(enumDescription("κ²Œμ‹œκΈ€ λŒ“κΈ€ ν™œμ„±ν™” μ—¬λΆ€", CommentActive.class)), + fieldWithPath("closeOption") + .type(JsonFieldType.OBJECT) + .description("νˆ¬ν‘œ 마감 μ˜΅μ…˜"), + fieldWithPath("closeOption.closeType") + .type(JsonFieldType.STRING) + .description(enumDescription("νˆ¬ν‘œ 마감 방식", CloseType.class)), + fieldWithPath("closeOption.closedAt") + .type(JsonFieldType.STRING) + .optional() + .description("νˆ¬ν‘œ 마감 μ‹œκ°„ (now + 1h < closedAt)"), + fieldWithPath("closeOption.maxVoterCount") + .type(JsonFieldType.NUMBER) + .optional() + .description("νˆ¬ν‘œ μ΅œλŒ€ μ°Έμ—¬μž 수 (1 < maxVoterCount < 999)") + ), + responseFields( + fieldWithPath("postId") + .type(JsonFieldType.NUMBER) + .description("κ²Œμ‹œκΈ€ Id"), + fieldWithPath("shareUrl") + .type(JsonFieldType.STRING) + .description("κ²Œμ‹œκΈ€ 곡유 url") + ) + )); + } + + @Test + @WithAnonymousUser + @DisplayName("κ²Œμ‹œκΈ€ 곡유 url 상세 쑰회") + void findPost_shareUrl() throws Exception { + PostResponse response = new PostResponse( + 1L, + "title", + "description", + new AuthorDto( + 1L, + "author", + "https://image.chooz.site/profile-image" + ), + List.of( + new PollChoiceVoteResponse(1L, "title1", "https://image.chooz.site/image/1", 1L), + new PollChoiceVoteResponse(2L, "title2", "https://image.chooz.site/image/2", null) + ), + "https://chooz.site/shareurl", + true, + Status.PROGRESS, + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.SELF, null, null), + 0L, + 1L, + LocalDateTime.of(2025, 2, 13, 12, 0) + ); + //given + given(postService.findByShareUrl(any(), any())) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/shareUrl/{shareUrl}", "JNOfBVfcG2z89afSiRrOyQ")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters( + parameterWithName("shareUrl").description("곡유 url") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ Id"), + fieldWithPath("title").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 제λͺ©"), + fieldWithPath("description").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μ„€λͺ…"), + fieldWithPath("author").type(JsonFieldType.OBJECT).description("κ²Œμ‹œκΈ€ μž‘μ„±μž 정보"), + fieldWithPath("author.id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μœ μ € Id"), + fieldWithPath("author.nickname").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž λ‹‰λ„€μž„"), + fieldWithPath("author.profileUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž ν”„λ‘œν•„ 이미지"), + fieldWithPath("pollChoices[]").type(JsonFieldType.ARRAY).description("νˆ¬ν‘œ 선택지 λͺ©λ‘"), + fieldWithPath("pollChoices[].id").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ 선택지 Id"), + fieldWithPath("pollChoices[].title").type(JsonFieldType.STRING).description("사진 이름"), + fieldWithPath("pollChoices[].imageUrl").type(JsonFieldType.STRING).description("사진 이미지"), + fieldWithPath("pollChoices[].voteId").type(JsonFieldType.NUMBER).optional().description("νˆ¬ν‘œ Id (νˆ¬ν‘œ μ•ˆ ν•œ 경우 null)"), + fieldWithPath("shareUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 곡유 URL"), + fieldWithPath("pollOption").type(JsonFieldType.OBJECT).description("νˆ¬ν‘œ μ„€μ •"), + fieldWithPath("pollOption.pollType").type(JsonFieldType.STRING).description(enumDescription("단일/볡수 νˆ¬ν‘œ", PollType.class)), + fieldWithPath("pollOption.scope").type(JsonFieldType.STRING).description(enumDescription("곡개 μ—¬λΆ€", Scope.class)), + fieldWithPath("pollOption.commentActive").type(JsonFieldType.STRING).description(enumDescription("λŒ“κΈ€ ν™œμ„±ν™” μ—¬λΆ€", CommentActive.class)), + fieldWithPath("closeOption").type(JsonFieldType.OBJECT).description("마감 μ„€μ •"), + fieldWithPath("closeOption.closeType").type(JsonFieldType.STRING).description(enumDescription("마감 방식", CloseType.class)), + fieldWithPath("closeOption.closedAt").type(JsonFieldType.STRING).optional().description("마감 μ‹œκ°„, (closeType이 DATE일 경우 NN)"), + fieldWithPath("closeOption.maxVoterCount").type(JsonFieldType.NUMBER).optional().description("남은 νˆ¬ν‘œ μ°Έμ—¬μž 수 (closeType이 VOTER_COUNT일 경우 NN)"), + fieldWithPath("commentCount").type(JsonFieldType.NUMBER).description("λŒ“κΈ€ 수"), + fieldWithPath("voterCount").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ μ°Έμ—¬μž 수"), + fieldWithPath("status").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 마감 μ—¬λΆ€ (PROGRESS, CLOSED)"), + fieldWithPath("isAuthor").type(JsonFieldType.BOOLEAN).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μ—¬λΆ€"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„± μ‹œκ°„") + ) + )); + } + + @Test + @WithAnonymousUser + @DisplayName("κ²Œμ‹œκΈ€ 상세 쑰회") + void findPost() throws Exception { + PostResponse response = new PostResponse( + 1L, + "title", + "description", + new AuthorDto( + 1L, + "author", + "https://image.chooz.site/profile-image" + ), + List.of( + new PollChoiceVoteResponse(1L, "title1", "https://image.chooz.site/image/1", 1L), + new PollChoiceVoteResponse(2L, "title2", "https://image.chooz.site/image/2", null) + ), + "https://chooz.site/shareurl", + true, + Status.PROGRESS, + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.SELF, null, null), + 0L, + 1L, + LocalDateTime.of(2025, 2, 13, 12, 0) + ); + //given + given(postService.findById(any(), any(), any())) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/{postId}", "1") + .param("shareKey", "shareKey")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters( + parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") + ), + queryParameters( + parameterWithName("shareKey").description("곡유 ν‚€").optional() + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ Id"), + fieldWithPath("title").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 제λͺ©"), + fieldWithPath("description").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μ„€λͺ…"), + fieldWithPath("author").type(JsonFieldType.OBJECT).description("κ²Œμ‹œκΈ€ μž‘μ„±μž 정보"), + fieldWithPath("author.id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μœ μ € Id"), + fieldWithPath("author.nickname").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž λ‹‰λ„€μž„"), + fieldWithPath("author.profileUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž ν”„λ‘œν•„ 이미지"), + fieldWithPath("pollChoices[]").type(JsonFieldType.ARRAY).description("νˆ¬ν‘œ 선택지 λͺ©λ‘"), + fieldWithPath("pollChoices[].id").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ 선택지 Id"), + fieldWithPath("pollChoices[].title").type(JsonFieldType.STRING).description("사진 이름"), + fieldWithPath("pollChoices[].imageUrl").type(JsonFieldType.STRING).description("사진 이미지"), + fieldWithPath("pollChoices[].voteId").type(JsonFieldType.NUMBER).optional().description("νˆ¬ν‘œ Id (νˆ¬ν‘œ μ•ˆ ν•œ 경우 null)"), + fieldWithPath("shareUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 곡유 URL"), + fieldWithPath("pollOption").type(JsonFieldType.OBJECT).description("νˆ¬ν‘œ μ„€μ •"), + fieldWithPath("pollOption.pollType").type(JsonFieldType.STRING).description(enumDescription("단일/볡수 νˆ¬ν‘œ", PollType.class)), + fieldWithPath("pollOption.scope").type(JsonFieldType.STRING).description(enumDescription("곡개 μ—¬λΆ€", Scope.class)), + fieldWithPath("pollOption.commentActive").type(JsonFieldType.STRING).description(enumDescription("λŒ“κΈ€ ν™œμ„±ν™” μ—¬λΆ€", CommentActive.class)), + fieldWithPath("closeOption").type(JsonFieldType.OBJECT).description("마감 μ„€μ •"), + fieldWithPath("closeOption.closeType").type(JsonFieldType.STRING).description(enumDescription("마감 방식", CloseType.class)), + fieldWithPath("closeOption.closedAt").type(JsonFieldType.STRING).optional().description("마감 μ‹œκ°„, (closeType이 DATE일 경우 NN)"), + fieldWithPath("closeOption.maxVoterCount").type(JsonFieldType.NUMBER).optional().description("남은 νˆ¬ν‘œ μ°Έμ—¬μž 수 (closeType이 VOTER_COUNT일 경우 NN)"), + fieldWithPath("commentCount").type(JsonFieldType.NUMBER).description("λŒ“κΈ€ 수"), + fieldWithPath("voterCount").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ μ°Έμ—¬μž 수"), + fieldWithPath("status").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 마감 μ—¬λΆ€ (PROGRESS, CLOSED)"), + fieldWithPath("isAuthor").type(JsonFieldType.BOOLEAN).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μ—¬λΆ€"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„± μ‹œκ°„") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("κ²Œμ‹œκΈ€ μ‚­μ œ") + void deletePost() throws Exception { + //given + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.delete("/posts/{postId}", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") + ) + )); + verify(postService, times(1)).delete(any(), any()); + } + + @Test + @WithMockUserInfo + @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회") + void findMyPost() throws Exception { + //given + var response = new CursorBasePaginatedResponse<>( + 1L, + false, + List.of( + new MyPagePostResponse( + 1L, + "title", + "https://image.chooz.site/1", + Status.PROGRESS, + new CloseOptionDto(CloseType.SELF, null, null), + new MyPagePostResponse.PostVoteInfo(5L, new MostVotedPollChoiceDto(1L, "title", 5, "50%")), + LocalDateTime.of(2025, 2, 13, 12, 0) + ) + ) + ); + given(postService.findUserPosts(1L, 1L, null, 10)) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/users/{userId}", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters(parameterWithName("userId").description("μœ μ € Id")), + requestHeaders(authorizationHeader()), + queryParameters(cursorQueryParams()), + responseFields( + fieldWithPath("nextCursor") + .type(JsonFieldType.NUMBER) + .optional() + .description("λ‹€μŒ 쑰회 μ»€μ„œ κ°’"), + fieldWithPath("hasNext") + .type(JsonFieldType.BOOLEAN) + .description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€ (κΈ°λ³Έ κ°’ 10)"), + fieldWithPath("data[]") + .type(JsonFieldType.ARRAY) + .description("κ²Œμ‹œκΈ€ 데이터"), + fieldWithPath("data[].id") + .type(JsonFieldType.NUMBER) + .description("κ²Œμ‹œκΈ€ Id"), + fieldWithPath("data[].title") + .type(JsonFieldType.STRING) + .description("κ²Œμ‹œκΈ€ 제λͺ©"), + fieldWithPath("data[].thumbnailImageUrl") + .type(JsonFieldType.STRING) + .description("썸넀일 이미지 URL"), + fieldWithPath("data[].status") + .type(JsonFieldType.STRING) + .description(enumDescription("κ²Œμ‹œκΈ€ μƒνƒœ", Status.class)), + fieldWithPath("data[].closeOptionDto") + .type(JsonFieldType.OBJECT) + .description("κ²Œμ‹œκΈ€ 마감 μ˜΅μ…˜"), + fieldWithPath("data[].closeOptionDto.closeType") + .type(JsonFieldType.STRING) + .description(enumDescription("마감 방식", CloseType.class)), + fieldWithPath("data[].closeOptionDto.closedAt") + .type(JsonFieldType.STRING) + .optional() + .description("마감 μ‹œκ°„ (closeType이 DATE일 경우)"), + fieldWithPath("data[].closeOptionDto.maxVoterCount") + .type(JsonFieldType.NUMBER) + .optional() + .description("μ΅œλŒ€ νˆ¬ν‘œμž 수 (closeType이 VOTER일 경우)"), + fieldWithPath("data[].postVoteInfo") + .type(JsonFieldType.OBJECT) + .description("κ²Œμ‹œκΈ€ νˆ¬ν‘œ 정보"), + fieldWithPath("data[].postVoteInfo.totalVoterCount") + .type(JsonFieldType.NUMBER) + .description("총 νˆ¬ν‘œμž 수"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice") + .type(JsonFieldType.OBJECT) + .optional() + .description("κ°€μž₯ λ§Žμ€ νˆ¬ν‘œλ₯Ό 받은 선택지 정보"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice.id") + .type(JsonFieldType.NUMBER) + .optional() + .description("선택지 ID"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice.title") + .type(JsonFieldType.STRING) + .optional() + .description("선택지 제λͺ©"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice.voteCount") + .type(JsonFieldType.NUMBER) + .optional() + .description("선택지 νˆ¬ν‘œ 수"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice.voteRatio") + .type(JsonFieldType.STRING) + .optional() + .description("선택지 νˆ¬ν‘œ λΉ„μœ¨"), + fieldWithPath("data[].createdAt") + .type(JsonFieldType.STRING) + .description("κ²Œμ‹œκΈ€ 생성 μ‹œκ°„") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("μœ μ €κ°€ μ°Έμ—¬ν•œ κ²Œμ‹œκΈ€ 쑰회") + void findVotedPost() throws Exception { + //given + var response = new CursorBasePaginatedResponse<>( + 1L, + false, + List.of( + new MyPagePostResponse( + 1L, + "title", + "https://image.chooz.site/1", + Status.PROGRESS, + new CloseOptionDto(CloseType.SELF, null, null), + new MyPagePostResponse.PostVoteInfo(5L, new MostVotedPollChoiceDto(1L, "title", 5, "50%")), + LocalDateTime.of(2025, 2, 13, 12, 0) + ) + ) + ); + given(postService.findVotedPosts(1L, 1L, null, 10)) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/users/{userId}/voted", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters(parameterWithName("userId").description("μœ μ € Id")), + requestHeaders(authorizationHeader()), + queryParameters(cursorQueryParams()), + responseFields( + fieldWithPath("nextCursor") + .type(JsonFieldType.NUMBER) + .optional() + .description("λ‹€μŒ 쑰회 μ»€μ„œ κ°’"), + fieldWithPath("hasNext") + .type(JsonFieldType.BOOLEAN) + .description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€ (κΈ°λ³Έ κ°’ 10)"), + fieldWithPath("data[]") + .type(JsonFieldType.ARRAY) + .description("κ²Œμ‹œκΈ€ 데이터"), + fieldWithPath("data[].id") + .type(JsonFieldType.NUMBER) + .description("κ²Œμ‹œκΈ€ Id"), + fieldWithPath("data[].title") + .type(JsonFieldType.STRING) + .description("κ²Œμ‹œκΈ€ 제λͺ©"), + fieldWithPath("data[].thumbnailImageUrl") + .type(JsonFieldType.STRING) + .description("썸넀일 이미지 URL"), + fieldWithPath("data[].status") + .type(JsonFieldType.STRING) + .description(enumDescription("κ²Œμ‹œκΈ€ μƒνƒœ", Status.class)), + fieldWithPath("data[].closeOptionDto") + .type(JsonFieldType.OBJECT) + .description("κ²Œμ‹œκΈ€ 마감 μ˜΅μ…˜"), + fieldWithPath("data[].closeOptionDto.closeType") + .type(JsonFieldType.STRING) + .description(enumDescription("마감 방식", CloseType.class)), + fieldWithPath("data[].closeOptionDto.closedAt") + .type(JsonFieldType.STRING) + .optional() + .description("마감 μ‹œκ°„ (closeType이 DATE일 경우)"), + fieldWithPath("data[].closeOptionDto.maxVoterCount") + .type(JsonFieldType.NUMBER) + .optional() + .description("μ΅œλŒ€ νˆ¬ν‘œμž 수 (closeType이 VOTER일 경우)"), + fieldWithPath("data[].postVoteInfo") + .type(JsonFieldType.OBJECT) + .description("κ²Œμ‹œκΈ€ νˆ¬ν‘œ 정보"), + fieldWithPath("data[].postVoteInfo.totalVoterCount") + .type(JsonFieldType.NUMBER) + .description("총 νˆ¬ν‘œμž 수"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice") + .type(JsonFieldType.OBJECT) + .optional() + .description("κ°€μž₯ λ§Žμ€ νˆ¬ν‘œλ₯Ό 받은 선택지 정보"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice.id") + .type(JsonFieldType.NUMBER) + .optional() + .description("선택지 ID"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice.title") + .type(JsonFieldType.STRING) + .optional() + .description("선택지 제λͺ©"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice.voteCount") + .type(JsonFieldType.NUMBER) + .optional() + .description("선택지 νˆ¬ν‘œ 수"), + fieldWithPath("data[].postVoteInfo.mostVotedPollChoice.voteRatio") + .type(JsonFieldType.STRING) + .optional() + .description("선택지 νˆ¬ν‘œ λΉ„μœ¨"), + fieldWithPath("data[].createdAt") + .type(JsonFieldType.STRING) + .description("κ²Œμ‹œκΈ€ 생성 μ‹œκ°„") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ •") + void updatePost() throws Exception { + //given + UpdatePostRequest request = new UpdatePostRequest( + "title", + "description", + List.of(), + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.SELF, null, null) + ); + + //when then + mockMvc.perform(put("/posts/{postId}", 1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") + ), + requestFields( + fieldWithPath("title") + .type(JsonFieldType.STRING) + .description("κ²Œμ‹œκΈ€ 제λͺ©") + .attributes(constraints("1~50자 사이")), + fieldWithPath("description") + .type(JsonFieldType.STRING) + .description("μ„€λͺ…") + .attributes(constraints("0~100자 사이")), + fieldWithPath("pollChoices") + .type(JsonFieldType.ARRAY) + .description("νˆ¬ν‘œ 선택지") + .attributes(constraints("μ΅œμ†Œ 2개 μ΅œλŒ€ 10개")), + fieldWithPath("pollOption") + .type(JsonFieldType.OBJECT) + .description("νˆ¬ν‘œ μ˜΅μ…˜"), + fieldWithPath("pollOption.scope") + .type(JsonFieldType.STRING) + .description(enumDescription("νˆ¬ν‘œ 곡개 λ²”μœ„", Scope.class)), + fieldWithPath("pollOption.pollType") + .type(JsonFieldType.STRING) + .description(enumDescription("νˆ¬ν‘œ 방식", PollType.class)), + fieldWithPath("pollOption.commentActive") + .type(JsonFieldType.STRING) + .description(enumDescription("κ²Œμ‹œκΈ€ λŒ“κΈ€ ν™œμ„±ν™” μ—¬λΆ€", CommentActive.class)), + fieldWithPath("closeOption") + .type(JsonFieldType.OBJECT) + .description("νˆ¬ν‘œ 마감 μ˜΅μ…˜"), + fieldWithPath("closeOption.closeType") + .type(JsonFieldType.STRING) + .description(enumDescription("νˆ¬ν‘œ 마감 방식", CloseType.class)), + fieldWithPath("closeOption.closedAt") + .type(JsonFieldType.STRING) + .optional() + .description("νˆ¬ν‘œ 마감 μ‹œκ°„ (now or createdAt + 1h < closedAt)"), + fieldWithPath("closeOption.maxVoterCount") + .type(JsonFieldType.NUMBER) + .optional() + .description("νˆ¬ν‘œ μ΅œλŒ€ μ°Έμ—¬μž 수 (1 or ν˜„μž¬ νˆ¬ν‘œ μ°Έμ—¬μž 수 < maxVoterCount < 999)") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ • 쑰회") + void findPost_update() throws Exception { + UpdatePostResponse response = new UpdatePostResponse( + 1L, + "title", + "description", + List.of( + new PollChoiceResponse(1L, "title1", "https://image.chooz.site/image/1"), + new PollChoiceResponse(2L, "title2", "https://image.chooz.site/image/2") + ), + "https://chooz.site/shareurl", + Status.PROGRESS, + new PollOptionDto(Scope.PUBLIC, PollType.SINGLE, CommentActive.OPEN), + new CloseOptionDto(CloseType.SELF, null, null), + LocalDateTime.of(2025, 2, 13, 12, 0) + ); + //given + given(postService.findUpdatePost(any(), any())) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/{postId}/update", "1") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + pathParameters( + parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ Id"), + fieldWithPath("title").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 제λͺ©"), + fieldWithPath("description").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μ„€λͺ…"), + fieldWithPath("pollChoices[]").type(JsonFieldType.ARRAY).description("νˆ¬ν‘œ 선택지 λͺ©λ‘"), + fieldWithPath("pollChoices[].id").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ 선택지 Id"), + fieldWithPath("pollChoices[].title").type(JsonFieldType.STRING).description("사진 이름"), + fieldWithPath("pollChoices[].imageUrl").type(JsonFieldType.STRING).description("사진 이미지"), + fieldWithPath("shareUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 곡유 URL"), + fieldWithPath("pollOption").type(JsonFieldType.OBJECT).description("νˆ¬ν‘œ μ„€μ •"), + fieldWithPath("pollOption.pollType").type(JsonFieldType.STRING).description(enumDescription("단일/볡수 νˆ¬ν‘œ", PollType.class)), + fieldWithPath("pollOption.scope").type(JsonFieldType.STRING).description(enumDescription("곡개 μ—¬λΆ€", Scope.class)), + fieldWithPath("pollOption.commentActive").type(JsonFieldType.STRING).description(enumDescription("λŒ“κΈ€ ν™œμ„±ν™” μ—¬λΆ€", CommentActive.class)), + fieldWithPath("closeOption").type(JsonFieldType.OBJECT).description("마감 μ„€μ •"), + fieldWithPath("closeOption.closeType").type(JsonFieldType.STRING).description(enumDescription("마감 방식", CloseType.class)), + fieldWithPath("closeOption.closedAt").type(JsonFieldType.STRING).optional().description("마감 μ‹œκ°„, (closeType이 DATE일 경우 NN)"), + fieldWithPath("closeOption.maxVoterCount").type(JsonFieldType.NUMBER).optional().description("남은 νˆ¬ν‘œ μ°Έμ—¬μž 수 (closeType이 VOTER_COUNT일 경우 NN)"), + fieldWithPath("status").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 마감 μ—¬λΆ€ (PROGRESS, CLOSED)"), + fieldWithPath("createdAt").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„± μ‹œκ°„") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("κ²Œμ‹œκΈ€ 마감") + void closeByAuthorPost() throws Exception { + //given + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.post("/posts/{postId}/close", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") + ) + )); + verify(postService, times(1)).close(any(), any()); + } + + @Test + @WithMockUserInfo + @DisplayName("ν”Όλ“œ 쑰회") + void findFeed() throws Exception { + //given + var response = new CursorBasePaginatedResponse<>( + 1L, + false, + List.of( + new FeedResponse( + 1L, + new AuthorDto( + 1L, + "author", + "https://image.chooz.site/profile-image" + ), + Status.PROGRESS, + "title", + "http://example.com/image/1", + true, + 1L, + 2L, + LocalDateTime.now() + ) + ) + ); + given(postService.findFeed(1L, null, 10)).willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/feed") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + queryParameters(cursorQueryParams()), + responseFields( + fieldWithPath("nextCursor").type(JsonFieldType.NUMBER).optional().description("λ‹€μŒ 쑰회 μ»€μ„œ κ°’"), + fieldWithPath("hasNext").type(JsonFieldType.BOOLEAN).description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€ (κΈ°λ³Έ κ°’ 10)"), + fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("κ²Œμ‹œκΈ€ 데이터"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ Id"), + fieldWithPath("data[].author").type(JsonFieldType.OBJECT).description("κ²Œμ‹œκΈ€ μž‘μ„±μž 정보"), + fieldWithPath("data[].author.id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μœ μ € ID"), + fieldWithPath("data[].author.nickname").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž λ‹‰λ„€μž„"), + fieldWithPath("data[].author.profileUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž ν”„λ‘œν•„ 이미지"), + fieldWithPath("data[].status").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 마감 μ—¬λΆ€ (PROGRESS, CLOSED)"), + fieldWithPath("data[].title").type(JsonFieldType.STRING).description("μ„€λͺ…"), + fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("썸넀일 이미지 url"), + fieldWithPath("data[].isAuthor").type(JsonFieldType.BOOLEAN).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μ—¬λΆ€"), + fieldWithPath("data[].voterCount").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ μ°Έμ—¬μž 수"), + fieldWithPath("data[].commentCount").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ λŒ“κΈ€ 수"), + fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„± λ‚ μ§œ") + ) + )); + } +} diff --git a/src/test/java/com/chooz/support/IntegrationTest.java b/src/test/java/com/chooz/support/IntegrationTest.java new file mode 100644 index 00000000..f20a8cbf --- /dev/null +++ b/src/test/java/com/chooz/support/IntegrationTest.java @@ -0,0 +1,11 @@ +package com.chooz.support; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@ActiveProfiles("test") +@Transactional +@SpringBootTest +public abstract class IntegrationTest { +} diff --git a/src/test/java/com/chooz/support/RepositoryTest.java b/src/test/java/com/chooz/support/RepositoryTest.java new file mode 100644 index 00000000..6307b12a --- /dev/null +++ b/src/test/java/com/chooz/support/RepositoryTest.java @@ -0,0 +1,32 @@ +package com.chooz.support; + +import com.chooz.common.config.JpaConfig; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Repository; + +@Import({JpaConfig.class, RepositoryTest.QueryDslConfig.class}) +@DataJpaTest( + includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Repository.class) +) +public abstract class RepositoryTest { + + @TestConfiguration + public static class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory queryFactory() { + return new JPAQueryFactory(entityManager); + } + } +} diff --git a/src/test/java/com/swyp8team2/support/RestDocsTest.java b/src/test/java/com/chooz/support/RestDocsTest.java similarity index 76% rename from src/test/java/com/swyp8team2/support/RestDocsTest.java rename to src/test/java/com/chooz/support/RestDocsTest.java index 8e72a1e0..ff2937b1 100644 --- a/src/test/java/com/swyp8team2/support/RestDocsTest.java +++ b/src/test/java/com/chooz/support/RestDocsTest.java @@ -1,8 +1,7 @@ -package com.swyp8team2.support; +package com.chooz.support; -import com.swyp8team2.common.presentation.CustomHeader; -import com.swyp8team2.support.config.RestDocsConfiguration; -import com.swyp8team2.support.config.TestSecurityConfig; +import com.chooz.support.config.RestDocsConfiguration; +import com.chooz.support.config.TestSecurityConfig; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; @@ -33,8 +32,16 @@ protected static HeaderDescriptor authorizationHeader() { return headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token"); } - protected static HeaderDescriptor guestHeader() { - return headerWithName(CustomHeader.GUEST_TOKEN).description("게슀트 토큰"); + protected static String enumDescription(String description, Class> enumClass) { + StringBuilder values = new StringBuilder(description + " ("); + for (Enum value : enumClass.getEnumConstants()) { + if (!values.isEmpty()) { + values.append(", "); + } + values.append(value.name()); + } + values.append(")"); + return values.toString(); } protected static ParameterDescriptor[] cursorQueryParams() { diff --git a/src/test/java/com/swyp8team2/support/WebUnitTest.java b/src/test/java/com/chooz/support/WebUnitTest.java similarity index 55% rename from src/test/java/com/swyp8team2/support/WebUnitTest.java rename to src/test/java/com/chooz/support/WebUnitTest.java index aa1f1395..67fe8194 100644 --- a/src/test/java/com/swyp8team2/support/WebUnitTest.java +++ b/src/test/java/com/chooz/support/WebUnitTest.java @@ -1,14 +1,18 @@ -package com.swyp8team2.support; +package com.chooz.support; +import com.chooz.image.application.ImageService; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.service.NotificationCommandService; +import com.chooz.notification.application.service.NotificationQueryService; import com.fasterxml.jackson.databind.ObjectMapper; -import com.swyp8team2.auth.application.AuthService; -import com.swyp8team2.auth.presentation.RefreshTokenCookieGenerator; -import com.swyp8team2.comment.application.CommentService; -import com.swyp8team2.common.exception.DiscordMessageSender; -import com.swyp8team2.image.application.ImageService; -import com.swyp8team2.post.application.PostService; -import com.swyp8team2.user.application.UserService; -import com.swyp8team2.vote.application.VoteService; +import com.chooz.auth.application.AuthService; +import com.chooz.auth.presentation.RefreshTokenCookieGenerator; +import com.chooz.comment.application.CommentService; +import com.chooz.commentLike.application.CommentLikeService; +import com.chooz.common.exception.DiscordMessageSender; +import com.chooz.post.application.PostService; +import com.chooz.user.application.UserService; +import com.chooz.vote.application.VoteService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; @@ -29,10 +33,10 @@ public abstract class WebUnitTest { protected RefreshTokenCookieGenerator refreshTokenCookieGenerator; @MockitoBean - protected AuthService authService; + protected ImageService imageService; @MockitoBean - protected ImageService imageService; + protected AuthService authService; @MockitoBean protected PostService postService; @@ -43,9 +47,15 @@ public abstract class WebUnitTest { @MockitoBean protected CommentService commentService; + @MockitoBean + protected CommentLikeService commentLikeService; + @MockitoBean protected UserService userService; @MockitoBean protected DiscordMessageSender discordMessageSender; + + @MockitoBean + protected NotificationService notificationService; } diff --git a/src/test/java/com/swyp8team2/support/WithMockUserInfo.java b/src/test/java/com/chooz/support/WithMockUserInfo.java similarity index 72% rename from src/test/java/com/swyp8team2/support/WithMockUserInfo.java rename to src/test/java/com/chooz/support/WithMockUserInfo.java index 5b42ff3e..a50b0ad5 100644 --- a/src/test/java/com/swyp8team2/support/WithMockUserInfo.java +++ b/src/test/java/com/chooz/support/WithMockUserInfo.java @@ -1,7 +1,7 @@ -package com.swyp8team2.support; +package com.chooz.support; -import com.swyp8team2.support.security.TestSecurityContextFactory; -import com.swyp8team2.user.domain.Role; +import com.chooz.support.security.TestSecurityContextFactory; +import com.chooz.user.domain.Role; import org.springframework.security.test.context.support.WithSecurityContext; import java.lang.annotation.Retention; diff --git a/src/test/java/com/swyp8team2/support/config/RestDocsConfiguration.java b/src/test/java/com/chooz/support/config/RestDocsConfiguration.java similarity index 97% rename from src/test/java/com/swyp8team2/support/config/RestDocsConfiguration.java rename to src/test/java/com/chooz/support/config/RestDocsConfiguration.java index 8848b5a8..c729fc17 100644 --- a/src/test/java/com/swyp8team2/support/config/RestDocsConfiguration.java +++ b/src/test/java/com/chooz/support/config/RestDocsConfiguration.java @@ -1,4 +1,4 @@ -package com.swyp8team2.support.config; +package com.chooz.support.config; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; diff --git a/src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java b/src/test/java/com/chooz/support/config/TestSecurityConfig.java similarity index 95% rename from src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java rename to src/test/java/com/chooz/support/config/TestSecurityConfig.java index 63961e27..bc6e83c6 100644 --- a/src/test/java/com/swyp8team2/support/config/TestSecurityConfig.java +++ b/src/test/java/com/chooz/support/config/TestSecurityConfig.java @@ -1,6 +1,5 @@ -package com.swyp8team2.support.config; +package com.chooz.support.config; -import com.swyp8team2.user.domain.Role; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.security.config.annotation.web.builders.HttpSecurity; diff --git a/src/test/java/com/chooz/support/fixture/CommentFixture.java b/src/test/java/com/chooz/support/fixture/CommentFixture.java new file mode 100644 index 00000000..1e9b512d --- /dev/null +++ b/src/test/java/com/chooz/support/fixture/CommentFixture.java @@ -0,0 +1,18 @@ +package com.chooz.support.fixture; + +import com.chooz.comment.domain.Comment; + +public class CommentFixture { + + public static Comment createDefaultComment(Long userId, Long postId) { + return Comment.create(postId, userId, "comment"); + } + + public static Comment.CommentBuilder createCommentBuilder() { + return Comment.builder() + .postId(1L) + .userId(1L) + .content("This is a comment") + .edited(false); + } +} diff --git a/src/test/java/com/chooz/support/fixture/CommentLikeFixture.java b/src/test/java/com/chooz/support/fixture/CommentLikeFixture.java new file mode 100644 index 00000000..c1165a85 --- /dev/null +++ b/src/test/java/com/chooz/support/fixture/CommentLikeFixture.java @@ -0,0 +1,18 @@ +package com.chooz.support.fixture; + +import com.chooz.commentLike.domain.CommentLike; + +public class CommentLikeFixture { + + public static CommentLike createDefaultCommentLike(Long userId, Long commentId) { + return createCommentLikeBuilder() + .userId(userId) + .commentId(commentId) + .build(); + } + public static CommentLike.CommentLikeBuilder createCommentLikeBuilder() { + return CommentLike.builder() + .commentId(1L) + .userId(1L); + } +} diff --git a/src/test/java/com/chooz/support/fixture/OnboardingStepFixture.java b/src/test/java/com/chooz/support/fixture/OnboardingStepFixture.java new file mode 100644 index 00000000..1023642b --- /dev/null +++ b/src/test/java/com/chooz/support/fixture/OnboardingStepFixture.java @@ -0,0 +1,17 @@ +package com.chooz.support.fixture; + +import com.chooz.user.domain.OnboardingStep; +import com.chooz.user.domain.User; + +public class OnboardingStepFixture { + + public static OnboardingStep createDefaultOnboardingStep() { + return createUserBuilder().build(); + } + + public static OnboardingStep.OnboardingStepBuilder createUserBuilder() { + return OnboardingStep.builder() + .welcomeGuide(false) + .firstVote(false); + } +} diff --git a/src/test/java/com/chooz/support/fixture/PostFixture.java b/src/test/java/com/chooz/support/fixture/PostFixture.java new file mode 100644 index 00000000..391adb73 --- /dev/null +++ b/src/test/java/com/chooz/support/fixture/PostFixture.java @@ -0,0 +1,82 @@ +package com.chooz.support.fixture; + +import com.chooz.post.domain.CloseOption; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.CommentActive; +import com.chooz.post.domain.PollChoice; +import com.chooz.post.domain.PollOption; +import com.chooz.post.domain.PollType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.Scope; +import com.chooz.post.domain.Status; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +public class PostFixture { + + public static Post createDefaultPost(Long userId) { + return createPostBuilder() + .userId(userId) + .build(); + } + + public static Post.PostBuilder createPostBuilder() { + return Post.builder() + .userId(1L) + .title("Default title") + .description("Default post description") + .imageUrl("https://example.com/image.png") + .shareUrl("http://example.com/post/1") + .status(Status.PROGRESS) + .closeOption(CloseOption.create(CloseType.SELF, null, null)) + .pollOption(PollOption.create(PollType.SINGLE, Scope.PUBLIC, CommentActive.OPEN)) + .pollChoices(List.of( + PollChoice.create("Choice A", "http://example.com/image/1"), + PollChoice.create("Choice B", "http://example.com/image/1") + )); + } + + public static Post createWithId(Long userId) { + Post post = createDefaultPost(userId); + ReflectionTestUtils.setField(post, "id", 1L); + ReflectionTestUtils.setField(post.getPollChoices().get(0), "id", 1L); + ReflectionTestUtils.setField(post.getPollChoices().get(1), "id", 2L); + return post; + } + + public static PollChoice createPollChoice() { + return PollChoice.create("Choice", "http://example.com/image/1"); + } + + public static CloseOption.CloseOptionBuilder createCloseOptionBuilder() { + return CloseOption.builder() + .closeType(CloseType.SELF) + .closedAt(null) + .maxVoterCount(null); + } + + public static CloseOption createCloseOptionOverDate() { + return new CloseOption(CloseType.DATE, LocalDateTime.now().minusMinutes(5), null); + } + + public static CloseOption voterCloseOption(int maxVoterCount) { + return new CloseOption(CloseType.VOTER, null, maxVoterCount); + } + + public static final CloseOption SELF_CREATE_OPTION = new CloseOption(CloseType.SELF, null, null); + + public static PollOption.PollOptionBuilder pollOptionBuilder() { + return PollOption.builder() + .pollType(PollType.SINGLE) + .scope(Scope.PUBLIC) + .commentActive(CommentActive.OPEN); + } + + public static PollOption multiplePollOption() { + return pollOptionBuilder() + .pollType(PollType.MULTIPLE) + .build(); + } +} diff --git a/src/test/java/com/chooz/support/fixture/ThumbnailFixture.java b/src/test/java/com/chooz/support/fixture/ThumbnailFixture.java new file mode 100644 index 00000000..30b33c75 --- /dev/null +++ b/src/test/java/com/chooz/support/fixture/ThumbnailFixture.java @@ -0,0 +1,18 @@ +package com.chooz.support.fixture; + +import com.chooz.thumbnail.domain.Thumbnail; +import com.chooz.vote.domain.Vote; + +public class ThumbnailFixture { + + public static Thumbnail createDefaultThumbnail(Long postId, Long pollChoiceId) { + return Thumbnail.create(postId, pollChoiceId, "http://example.com/image"); + } + + public static Thumbnail.ThumbnailBuilder createThumbnailBuilder() { + return Thumbnail.builder() + .postId(1L) + .pollChoiceId(1L) + .thumbnailUrl("http://example.com/image"); + } +} diff --git a/src/test/java/com/chooz/support/fixture/UserFixture.java b/src/test/java/com/chooz/support/fixture/UserFixture.java new file mode 100644 index 00000000..16de869c --- /dev/null +++ b/src/test/java/com/chooz/support/fixture/UserFixture.java @@ -0,0 +1,23 @@ +package com.chooz.support.fixture; + +import com.chooz.user.domain.User; + +import static com.chooz.support.fixture.OnboardingStepFixture.createDefaultOnboardingStep; + +public class UserFixture { + + public static User createDefaultUser() { + return createUserBuilder().build(); + } + public static User createUserWithNickname (String nickname) { + return createUserBuilder().nickname(nickname).build(); + } + + public static User.UserBuilder createUserBuilder() { + return User.builder() + .nickname("nickname") + .profileUrl("https://cdn.chooz.com/default_profile.png") + .notification(false) + .onboardingStep(createDefaultOnboardingStep()); + } +} diff --git a/src/test/java/com/chooz/support/fixture/VoteFixture.java b/src/test/java/com/chooz/support/fixture/VoteFixture.java new file mode 100644 index 00000000..2e9d1e62 --- /dev/null +++ b/src/test/java/com/chooz/support/fixture/VoteFixture.java @@ -0,0 +1,17 @@ +package com.chooz.support.fixture; + +import com.chooz.vote.domain.Vote; + +public class VoteFixture { + + public static Vote createDefaultVote(Long userId, Long postId, Long pollChoiceId) { + return Vote.create(userId, postId, pollChoiceId); + } + + public static Vote.VoteBuilder createVoteBuilder() { + return Vote.builder() + .postId(1L) + .pollChoiceId(1L) + .userId(1L); + } +} diff --git a/src/test/java/com/swyp8team2/support/security/TestSecurityContextFactory.java b/src/test/java/com/chooz/support/security/TestSecurityContextFactory.java similarity index 85% rename from src/test/java/com/swyp8team2/support/security/TestSecurityContextFactory.java rename to src/test/java/com/chooz/support/security/TestSecurityContextFactory.java index e6a06d4a..eae9a862 100644 --- a/src/test/java/com/swyp8team2/support/security/TestSecurityContextFactory.java +++ b/src/test/java/com/chooz/support/security/TestSecurityContextFactory.java @@ -1,8 +1,8 @@ -package com.swyp8team2.support.security; +package com.chooz.support.security; -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.support.WithMockUserInfo; -import com.swyp8team2.user.domain.Role; +import com.chooz.auth.domain.UserInfo; +import com.chooz.support.WithMockUserInfo; +import com.chooz.user.domain.Role; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; diff --git a/src/test/java/com/chooz/user/application/NicknameGeneratorTest.java b/src/test/java/com/chooz/user/application/NicknameGeneratorTest.java new file mode 100644 index 00000000..dac5974e --- /dev/null +++ b/src/test/java/com/chooz/user/application/NicknameGeneratorTest.java @@ -0,0 +1,42 @@ +package com.chooz.user.application; + +import com.chooz.user.domain.NicknameAdjective; +import com.chooz.user.domain.NicknameAdjectiveRepository; +import com.chooz.user.domain.UserRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class NicknameGeneratorTest { + + @InjectMocks + NicknameGenerator nicknameGenerator; + + @Mock + NicknameAdjectiveRepository nicknameAdjectiveRepository; + + @Mock + UserRepository userRepository; + + @Test + @DisplayName("λ‹‰λ„€μž„ 생성 ν…ŒμŠ€νŠΈ") + void generate() throws Exception { + //given + given(nicknameAdjectiveRepository.findRandomNicknameAdjective()) + .willReturn(Optional.of(new NicknameAdjective("ν˜ΈκΈ°μ‹¬ λ§Žμ€"))); + + //when + String nickname = nicknameGenerator.generate(); + + //then + Assertions.assertThat(nickname).isEqualTo("ν˜ΈκΈ°μ‹¬ λ§Žμ€ μΈ„"); + } +} diff --git a/src/test/java/com/chooz/user/application/UserServiceTest.java b/src/test/java/com/chooz/user/application/UserServiceTest.java new file mode 100644 index 00000000..ad107ede --- /dev/null +++ b/src/test/java/com/chooz/user/application/UserServiceTest.java @@ -0,0 +1,164 @@ +package com.chooz.user.application; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.NicknameAdjective; +import com.chooz.user.domain.NicknameAdjectiveRepository; +import com.chooz.user.domain.OnboardingStep; +import com.chooz.user.domain.OnboardingStepType; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.user.presentation.dto.OnboardingRequest; +import com.chooz.user.presentation.dto.UpdateUserRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class UserServiceTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + NicknameAdjectiveRepository nicknameAdjectiveRepository; + + @Autowired + NicknameGenerator nicknameGenerator; + + @Autowired + UserService userService; + + private void saveNickNameAdjective(String... adjectives) { + for(String adjective : adjectives){ + nicknameAdjectiveRepository.save(new NicknameAdjective(adjective)); + } + } + private User saveUser(){ + User user = UserFixture.createUserWithNickname(nicknameGenerator.generate()); + return userRepository.save(user); + } + @Test + @DisplayName("μœ μ €μƒμ„± ν…ŒμŠ€νŠΈ") + void createUser() { + // given + saveNickNameAdjective("ν˜ΈκΈ°μ‹¬ λ§Žμ€", "λ°°λ € κΉŠμ€"); + User user = saveUser(); + + // when then + assertAll( + () -> assertThat(user.getNickname()).isNotNull(), + () -> assertThat(user.getNickname()).contains("μΈ„") + ); + } + + @Test + @DisplayName("μœ μ €μƒμ„± 닉λ„₯μž„ 쀑볡 ν…ŒμŠ€νŠΈ") + void createUser_duplicateNickname() { + // given + saveNickNameAdjective("ν˜ΈκΈ°μ‹¬ λ§Žμ€"); + User user = saveUser(); + User user2 = saveUser(); + + // when then + assertAll( + () -> assertThat(user.getNickname()).isNotNull(), + () -> assertThat(user.getNickname()).contains("μΈ„"), + () -> assertThat(user.getNickname()).isNotEqualTo(user2.getNickname()), + () -> assertThat(user.getNickname()).isEqualTo("ν˜ΈκΈ°μ‹¬ λ§Žμ€ μΈ„"), + () -> assertThat(user2.getNickname()).isEqualTo("ν˜ΈκΈ°μ‹¬ λ§Žμ€ μΈ„1") + ); + } + + @Test + @DisplayName("μœ μ €μƒμ„± 닉λ„₯μž„ μ‚¬μš©κ°€λŠ₯ν•œ κ°€μž₯ μž‘μ€ suffix 선택 ν…ŒμŠ€νŠΈ") + void createUser_minSuffix() { + // given + saveNickNameAdjective("ν˜ΈκΈ°μ‹¬ λ§Žμ€"); + User user = saveUser(); + User user1 = saveUser(); + User user2 = saveUser(); + userRepository.delete(user1); + + // when + User user3 = saveUser(); + + // when then + assertAll( + () -> assertThat(user.getNickname()).isEqualTo("ν˜ΈκΈ°μ‹¬ λ§Žμ€ μΈ„"), + () -> assertThat(user2.getNickname()).isEqualTo("ν˜ΈκΈ°μ‹¬ λ§Žμ€ μΈ„2"), + () -> assertThat(user3.getNickname()).isEqualTo("ν˜ΈκΈ°μ‹¬ λ§Žμ€ μΈ„1") + ); + } + + + @Test + @DisplayName("μœ μ € 정보 μˆ˜μ • ν…ŒμŠ€νŠΈ") + void updateUser() { + // given + saveNickNameAdjective("ν˜ΈκΈ°μ‹¬ λ§Žμ€"); + User user = saveUser(); + + // when + UpdateUserRequest updateUserRequest = new UpdateUserRequest( + "이직 ν•˜λŠ” μΈ„", + "https://cdn.chooz.site/looking_job_chu.png" + ); + userService.updateUser(user.getId(), updateUserRequest); + + // when then + assertAll( + () -> assertThat(user.getNickname()).isEqualTo("이직 ν•˜λŠ” μΈ„"), + () -> assertThat(user.getProfileUrl()).contains("looking_job_chu.png") + ); + } + + + @Test + @DisplayName("μ˜¨λ³΄λ”© μˆ˜ν–‰ ν…ŒμŠ€νŠΈ") + void user_complete_onboarding_step() { + // given + Long userId = saveUser().getId(); + OnboardingRequest onboardingRequest = new OnboardingRequest( + Map.of( + OnboardingStepType.WELCOME_GUIDE, true, + OnboardingStepType.FIRST_VOTE, false + ) + ); + // when + userService.completeStep(userId, onboardingRequest); + OnboardingStep onboardingStep + = userRepository.findById(userId).get().getOnboardingStep(); + // then + assertAll( + () -> assertThat(onboardingStep.isWelcomeGuide()).isTrue(), + () -> assertThat(onboardingStep.isFirstVote()).isFalse() + ); + } + + @Test + @DisplayName("μ˜¨λ³΄λ”© μš”μ²­ μ˜ˆμ™Έ ν…ŒμŠ€νŠΈ") + void user_complete_onboarding_step_exception() { + // given + Long userId = saveUser().getId(); + OnboardingRequest onboardingRequest = new OnboardingRequest( + Map.of( + OnboardingStepType.WELCOME_GUIDE, false, + OnboardingStepType.FIRST_VOTE, false + ) + ); + + // when then + assertThatThrownBy( + () -> userService.completeStep(userId, onboardingRequest)) + .isInstanceOf(BadRequestException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ONBOARDING_STEP + ); + } +} diff --git a/src/test/java/com/swyp8team2/user/domain/UserTest.java b/src/test/java/com/chooz/user/domain/UserTest.java similarity index 84% rename from src/test/java/com/swyp8team2/user/domain/UserTest.java rename to src/test/java/com/chooz/user/domain/UserTest.java index 104c44f7..c132f8cf 100644 --- a/src/test/java/com/swyp8team2/user/domain/UserTest.java +++ b/src/test/java/com/chooz/user/domain/UserTest.java @@ -1,4 +1,4 @@ -package com.swyp8team2.user.domain; +package com.chooz.user.domain; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,7 +9,7 @@ class UserTest { @Test @DisplayName("user Entity 생성") - void create() throws Exception { + void create() { //given String nickname = "nickname"; diff --git a/src/test/java/com/chooz/user/presentation/UserControllerTest.java b/src/test/java/com/chooz/user/presentation/UserControllerTest.java new file mode 100644 index 00000000..0d297e17 --- /dev/null +++ b/src/test/java/com/chooz/user/presentation/UserControllerTest.java @@ -0,0 +1,248 @@ +package com.chooz.user.presentation; + +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.CommentActive; +import com.chooz.post.domain.PollType; +import com.chooz.post.domain.Scope; +import com.chooz.support.RestDocsTest; +import com.chooz.support.WithMockUserInfo; +import com.chooz.user.domain.OnboardingStepType; +import com.chooz.user.presentation.dto.OnboardingRequest; +import com.chooz.user.presentation.dto.UpdateUserRequest; +import com.chooz.user.presentation.dto.UserInfoResponse; +import com.chooz.user.presentation.dto.UserMyInfoResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class UserControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("μœ μ € 정보 쑰회") + void findUserInfo() throws Exception { + //given + Map onboardingStep = Map.of( + "WELCOME_GUIDE", false, + "FIRST_VOTE", true + ); + UserInfoResponse response = new UserInfoResponse( + 1L, + "nickname", + "https://cdn.chooz.site/default_profile.png", + false, + onboardingStep + ); + given(userService.findById(1L)) + .willReturn(response); + System.out.println(objectMapper.writeValueAsString(response)); + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/users/{userId}", "1") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters( + parameterWithName("userId").description("μœ μ € 아이디") + ), + responseFields( + fieldWithPath("id") + .description("μœ μ € 아이디") + .type(JsonFieldType.NUMBER), + fieldWithPath("nickname") + .description("λ‹‰λ„€μž„") + .type(JsonFieldType.STRING), + fieldWithPath("profileImageUrl") + .description("ν”„λ‘œν•„ 이미지 URL") + .type(JsonFieldType.STRING), + fieldWithPath("notification") + .description("μ•Œλ¦Ό μ„€μ • μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep") + .description("μœ μ € μ˜¨λ³΄λ”© 단계") + .type(JsonFieldType.OBJECT), + fieldWithPath("onboardingStep.WELCOME_GUIDE") + .description("μ›°μ»΄ κ°€μ΄λ“œ μ™„λ£Œ μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep.FIRST_VOTE") + .description("첫 νˆ¬ν‘œ μ™„λ£Œ μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN) + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("본인 정보 쑰회") + void findMe() throws Exception { + //given + Map onboardingStep = Map.of( + "WELCOME_GUIDE", false, + "FIRST_VOTE", true + ); + UserMyInfoResponse response = new UserMyInfoResponse( + 1L, + "nickname", + "https://cdn.chooz.site/default_profile.png", + false, + onboardingStep + ); + given(userService.findByMe(1L)) + .willReturn(response); + + //when then + mockMvc.perform(RestDocumentationRequestBuilders.get("/users/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + responseFields( + fieldWithPath("id") + .description("μœ μ € 아이디") + .type(JsonFieldType.NUMBER), + fieldWithPath("nickname") + .description("λ‹‰λ„€μž„") + .type(JsonFieldType.STRING), + fieldWithPath("profileImageUrl") + .description("ν”„λ‘œν•„ 이미지 URL") + .type(JsonFieldType.STRING), + fieldWithPath("notification") + .description("μ•Œλ¦Ό μ„€μ • μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep") + .description("μœ μ € μ˜¨λ³΄λ”© 단계") + .type(JsonFieldType.OBJECT), + fieldWithPath("onboardingStep.WELCOME_GUIDE") + .description("μ›°μ»΄ κ°€μ΄λ“œ μ™„λ£Œ μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep.FIRST_VOTE") + .description("첫 νˆ¬ν‘œ μ™„λ£Œ μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN) + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("본인 정보 μˆ˜μ •") + void updateMe() throws Exception { + //given + UpdateUserRequest updateUserRequest = new UpdateUserRequest( + "nickname", + "https://cdn.chooz.site/default_profile.png" + ); + + //when then + mockMvc.perform(put("/users/me") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateUserRequest)) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + requestFields( + fieldWithPath("nickname") + .type(JsonFieldType.STRING) + .description("λ‹‰λ„€μž„") + .attributes(constraints("1~15자 사이")), + fieldWithPath("profileImageUrl") + .type(JsonFieldType.STRING) + .description("ν”„λ‘œν•„ 이미지 경둜") + ) + )); + } + + @Test + @WithMockUserInfo + @DisplayName("μ˜¨λ³΄λ”© μˆ˜ν–‰") + void completeStep () throws Exception { + // given + Map steps = Map.of( + OnboardingStepType.WELCOME_GUIDE, false, + OnboardingStepType.FIRST_VOTE, true + + ); + OnboardingRequest request = new OnboardingRequest(steps); + + Map responseSteps = Map.of( + "WELCOME_GUIDE", false, + "FIRST_VOTE", true + + ); + UserInfoResponse response = new UserInfoResponse( + 1L, + "nickname", + "https://cdn.chooz.site/default_profile.png", + false, + responseSteps + ); + + given(userService.completeStep(eq(1L), any(OnboardingRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(RestDocumentationRequestBuilders.patch("/users/onboarding") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + requestFields( + fieldWithPath("onboardingStep") + .description("μ˜¨λ³΄λ”© 단계") + .type(JsonFieldType.OBJECT), + fieldWithPath("onboardingStep.WELCOME_GUIDE") + .description("μ›°μ»΄ κ°€μ΄λ“œ μ™„λ£Œ μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep.FIRST_VOTE") + .description("첫 νˆ¬ν‘œ μ™„λ£Œ μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN) + ), + responseFields( + fieldWithPath("id") + .description("μœ μ € 아이디") + .type(JsonFieldType.NUMBER), + fieldWithPath("nickname") + .description("λ‹‰λ„€μž„") + .type(JsonFieldType.STRING), + fieldWithPath("profileImageUrl") + .description("ν”„λ‘œν•„ 이미지 URL") + .type(JsonFieldType.STRING), + fieldWithPath("notification") + .description("μ•Œλ¦Ό μ„€μ • μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep") + .description("μœ μ € μ˜¨λ³΄λ”© 단계") + .type(JsonFieldType.OBJECT), + fieldWithPath("onboardingStep.WELCOME_GUIDE") + .description("μ›°μ»΄ κ°€μ΄λ“œ μ™„λ£Œ μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep.FIRST_VOTE") + .description("첫 νˆ¬ν‘œ μ™„λ£Œ μ—¬λΆ€") + .type(JsonFieldType.BOOLEAN) + ) + )); + } +} diff --git a/src/test/java/com/swyp8team2/vote/application/RatioCalculatorTest.java b/src/test/java/com/chooz/vote/application/RatioCalculatorTest.java similarity index 81% rename from src/test/java/com/swyp8team2/vote/application/RatioCalculatorTest.java rename to src/test/java/com/chooz/vote/application/RatioCalculatorTest.java index 8502377d..3893e71e 100644 --- a/src/test/java/com/swyp8team2/vote/application/RatioCalculatorTest.java +++ b/src/test/java/com/chooz/vote/application/RatioCalculatorTest.java @@ -1,4 +1,4 @@ -package com.swyp8team2.vote.application; +package com.chooz.vote.application; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -17,7 +17,7 @@ void setUp() { } @ParameterizedTest(name = "{index}: totalVoteCount={0}, voteCount={1} => result={2}") - @CsvSource({"3, 2, 66.7", "3, 1, 33.3", "4, 2, 50.0", "4, 3, 75.0", "0, 0, 0.0", "1, 0, 0.0", "1, 1, 100.0", "10, 7, 70.0", "10, 3, 30.0"}) + @CsvSource({"3, 2, 67", "3, 1, 33", "4, 2, 50", "4, 3, 75", "0, 0, 0", "1, 0, 0", "1, 1, 100", "10, 7, 70", "10, 3, 30"}) @DisplayName("λΉ„μœ¨ 계산") void calculate(int totalVoteCount, int voteCount, String result) throws Exception { //given diff --git a/src/test/java/com/chooz/vote/application/VoteConcurrentTest.java b/src/test/java/com/chooz/vote/application/VoteConcurrentTest.java new file mode 100644 index 00000000..d4b924e7 --- /dev/null +++ b/src/test/java/com/chooz/vote/application/VoteConcurrentTest.java @@ -0,0 +1,91 @@ +package com.chooz.vote.application; + +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.post.persistence.PostJpaRepository; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.domain.Vote; +import com.chooz.vote.persistence.VoteJpaRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +//@ActiveProfiles("mysql") +//@SpringBootTest +class VoteConcurrentTest { + + @Autowired + VoteService voteService; + + @Autowired + UserRepository userRepository; + + @Autowired + VoteJpaRepository voteRepository; + + @Autowired + PostJpaRepository postRepository; + + @AfterEach + void setUp() { + voteRepository.deleteAll(); + postRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @Disabled + void concurrentTest() throws Exception { + // given + int maxVoterCount = 4; + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .closeOption(PostFixture.voterCloseOption(maxVoterCount)) + .build()); + Long pollChoiceId = post.getPollChoices().getFirst().getId(); + + int threadCount = 30; + List users = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + users.add(userRepository.save(UserFixture.createDefaultUser())); + } + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + Long voterId = users.get(index).getId(); + + voteService.vote(voterId, post.getId(), List.of(pollChoiceId)); + } catch (Exception e) { + System.out.println("μ˜ˆμ™Έ λ°œμƒ: " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + // then + List voteList = voteRepository.findAllByPostIdAndDeletedFalse(post.getId()); + assertThat(voteList).hasSize(maxVoterCount); + } +} diff --git a/src/test/java/com/chooz/vote/application/VoteServiceTest.java b/src/test/java/com/chooz/vote/application/VoteServiceTest.java new file mode 100644 index 00000000..ce7e4083 --- /dev/null +++ b/src/test/java/com/chooz/vote/application/VoteServiceTest.java @@ -0,0 +1,374 @@ +package com.chooz.vote.application; + +import com.chooz.common.event.EventPublisher; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.*; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.domain.Vote; +import com.chooz.vote.persistence.VoteJpaRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class VoteServiceTest extends IntegrationTest { + + @Autowired + VoteService voteService; + + @Autowired + UserRepository userRepository; + + @Autowired + VoteJpaRepository voteRepository; + + @Autowired + PostRepository postRepository; + + @MockitoBean + EventPublisher eventPublisher; + + @Test + @DisplayName("단일 νˆ¬ν‘œν•˜κΈ°") + void singleVote() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .pollOption( + PostFixture.pollOptionBuilder() + .pollType(PollType.SINGLE) + .build()) + .build() + ); + Long pollChoiceId = post.getPollChoices().getFirst().getId(); + + // when + List voteIds = voteService.vote(user.getId(), post.getId(), List.of(pollChoiceId)); + + // then + Vote vote = voteRepository.findById(voteIds.getFirst()).orElseThrow(); + assertAll( + () -> assertThat(vote.getUserId()).isEqualTo(user.getId()), + () -> assertThat(vote.getPostId()).isEqualTo(post.getId()), + () -> assertThat(vote.getPollChoiceId()).isEqualTo(pollChoiceId), + () -> verify(eventPublisher, times(1)).publish(any(VotedEvent.class)) + ); + } + + @Test + @DisplayName("단일 νˆ¬ν‘œν•˜κΈ° - λ‹€λ₯Έ μ΄λ―Έμ§€λ‘œ νˆ¬ν‘œ λ³€κ²½ν•œ 경우") + void singleVote_change() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .pollOption( + PostFixture.pollOptionBuilder() + .pollType(PollType.SINGLE) + .build()) + .build()); + List pollChoices = post.getPollChoices(); + Long before = pollChoices.get(0).getId(); + Long after = pollChoices.get(1).getId(); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), before)); + + // when + List voteIds = voteService.vote(user.getId(), post.getId(), List.of(after)); + + // then + Vote vote = voteRepository.findById(voteIds.get(1)).orElseThrow(); + assertThat(vote.getPollChoiceId()).isEqualTo(after); + } + + @Test + @DisplayName("단일 νˆ¬ν‘œ - 빈 λ°°μ—΄λ‘œ νˆ¬ν‘œ μ‹œ κΈ°μ‘΄ νˆ¬ν‘œ μ·¨μ†Œ") + void singleVote_cancelByEmptyChoice() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .pollOption(PostFixture.pollOptionBuilder().pollType(PollType.SINGLE).build()) + .build() + ); + Long pollChoiceId = post.getPollChoices().getFirst().getId(); + voteService.vote(user.getId(), post.getId(), List.of(pollChoiceId)); + + // when + voteService.vote(user.getId(), post.getId(), List.of()); + + // then + List votes = voteRepository.findByUserIdAndPostIdAndDeletedFalse(user.getId(), post.getId()); + assertThat(votes).isEmpty(); + } + + @Test + @DisplayName("볡수 νˆ¬ν‘œν•˜κΈ° - μ—¬λŸ¬ 선택지 ν•œ λ²ˆμ—") + void multipleVote_atOnce() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .pollOption( + PostFixture.pollOptionBuilder() + .pollType(PollType.MULTIPLE) + .build()) + .build() + ); + List pollChoices = post.getPollChoices(); + List choiceIds = List.of(pollChoices.get(0).getId(), pollChoices.get(1).getId()); + + // when + List voteIds = voteService.vote(user.getId(), post.getId(), choiceIds); + + // then + List votes = voteRepository.findAllByPostIdAndDeletedFalse(post.getId()); + assertThat(votes).hasSize(2); + assertThat(votes).allMatch(v -> v.getUserId().equals(user.getId())); + assertThat(votes).extracting(Vote::getPollChoiceId) + .containsExactlyInAnyOrderElementsOf(choiceIds); + } + + @Test + @DisplayName("볡수 νˆ¬ν‘œ - κΈ°μ‘΄ νˆ¬ν‘œμ™€ λ‹€λ₯Έ μ„ νƒμ§€λ‘œ λ³€κ²½") + void multipleVote_changeChoices() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .pollChoices(List.of( + PostFixture.createPollChoice(), + PostFixture.createPollChoice(), + PostFixture.createPollChoice()) + ) + .pollOption(PostFixture.pollOptionBuilder() + .pollType(PollType.MULTIPLE) + .build()) + .build() + ); + List pollChoices = post.getPollChoices(); + Long first = pollChoices.get(0).getId(); + Long second = pollChoices.get(1).getId(); + Long third = pollChoices.get(2).getId(); + voteService.vote(user.getId(), post.getId(), List.of(first, second)); + + // when + List voteIds = voteService.vote(user.getId(), post.getId(), List.of(second, third)); + + // then + List votes = voteRepository.findAllByPostIdAndDeletedFalse(post.getId()); + assertThat(votes).hasSize(2); + assertThat(votes).allMatch(v -> v.getUserId().equals(user.getId())); + assertThat(votes).extracting(Vote::getPollChoiceId) + .containsExactlyInAnyOrder(second, third); + } + + @Test + @DisplayName("νˆ¬ν‘œν•˜κΈ° - νˆ¬ν‘œ 마감된 경우") + void vote_alreadyClosed() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .status(Status.CLOSED) + .build() + ); + Long pollChoiceId = post.getPollChoices().getFirst().getId(); + + // when & then + assertThatThrownBy(() -> voteService.vote(user.getId(), post.getId(), List.of(pollChoiceId))) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + } + + @Test + @DisplayName("νˆ¬ν‘œν•˜κΈ° - μ°Έμ—¬μž 초과된 경우") + void vote_exceedMaxVoterCount() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.VOTER) + .maxVoterCount(1) + .build()) + .build() + ); + Long pollChoiceId = post.getPollChoices().getFirst().getId(); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), pollChoiceId)); + + // when & then + assertThatThrownBy(() -> voteService.vote(user.getId(), post.getId(), List.of(pollChoiceId))) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.EXCEED_MAX_VOTER_COUNT.getMessage()); + } + + @Test + @DisplayName("단일 νˆ¬ν‘œ - 선택지가 μ—¬λŸ¬ 개 λ“€μ–΄μ˜¨ 경우 μ˜ˆμ™Έ") + void singleVote_multipleChoicesException() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .pollOption( + PostFixture.pollOptionBuilder() + .pollType(PollType.SINGLE) + .build()) + .build() + ); + List pollChoiceIds = post.getPollChoices().stream().map(PollChoice::getId).toList(); + + // when & then + assertThatThrownBy(() -> voteService.vote(user.getId(), post.getId(), pollChoiceIds)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.SINGLE_POLL_ALLOWS_MAXIMUM_ONE_CHOICE.getMessage()); + } + + @Test + @DisplayName("볡수 νˆ¬ν‘œ - μ€‘λ³΅λœ 선택지가 λ“€μ–΄μ˜¨ 경우 μ˜ˆμ™Έ") + void multipleVote_duplicateChoicesException() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .pollOption( + PostFixture.pollOptionBuilder() + .pollType(PollType.MULTIPLE) + .build()) + .build() + ); + Long pollChoiceId = post.getPollChoices().getFirst().getId(); + List pollChoiceIds = List.of(pollChoiceId, pollChoiceId); // 쀑볡 + + // when & then + assertThatThrownBy(() -> voteService.vote(user.getId(), post.getId(), pollChoiceIds)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DUPLICATE_POLL_CHOICE.getMessage()); + } + + @Test + @DisplayName("볡수 νˆ¬ν‘œ - λͺ¨λ“  선택지 μ œμ™Έ μ‹œ 전체 νˆ¬ν‘œ μ·¨μ†Œ") + void multipleVote_cancelAllChoices() { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save( + PostFixture.createPostBuilder() + .pollOption(PostFixture.pollOptionBuilder().pollType(PollType.MULTIPLE).build()) + .build() + ); + List pollChoices = post.getPollChoices(); + Long first = pollChoices.get(0).getId(); + Long second = pollChoices.get(1).getId(); + voteService.vote(user.getId(), post.getId(), List.of(first, second)); + + // when + voteService.vote(user.getId(), post.getId(), List.of()); + + // then + List votes = voteRepository.findByUserIdAndPostIdAndDeletedFalse(user.getId(), post.getId()); + assertThat(votes).isEmpty(); + } + + @Test + @DisplayName("νˆ¬ν‘œ ν˜„ν™© 쑰회") + void findVoteResult() { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + int voteIndex = 1; + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(voteIndex).getId())); + + //when + var response = voteService.findVoteResult(user.getId(), post.getId()); + + //then + assertAll( + () -> assertThat(response).hasSize(2), + () -> assertThat(response.getFirst().id()).isEqualTo(post.getPollChoices().get(1).getId()), + () -> assertThat(response.getFirst().title()).isEqualTo(post.getPollChoices().get(1).getTitle()), + () -> assertThat(response.getFirst().voteCount()).isEqualTo(1), + () -> assertThat(response.getFirst().voteRatio()).isEqualTo("100"), + + () -> assertThat(response.get(1).id()).isEqualTo(post.getPollChoices().getFirst().getId()), + () -> assertThat(response.get(1).title()).isEqualTo(post.getPollChoices().getFirst().getTitle()), + () -> assertThat(response.get(1).voteCount()).isEqualTo(0), + () -> assertThat(response.get(1).voteRatio()).isEqualTo("0") + ); + } + + @Test + @DisplayName("νˆ¬ν‘œ ν˜„ν™© 쑰회 - 쀑볡 νˆ¬ν‘œ") + void findVoteResult_multiple() { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .pollOption(PostFixture.multiplePollOption()) + .build()); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(1).getId())); + + //when + var response = voteService.findVoteResult(user.getId(), post.getId()); + + //then + assertAll( + () -> assertThat(response).hasSize(2), + () -> assertThat(response.getFirst().id()).isEqualTo(post.getPollChoices().get(0).getId()), + () -> assertThat(response.getFirst().title()).isEqualTo(post.getPollChoices().get(0).getTitle()), + () -> assertThat(response.getFirst().voteCount()).isEqualTo(1), + () -> assertThat(response.getFirst().voteRatio()).isEqualTo("50"), + + () -> assertThat(response.get(1).id()).isEqualTo(post.getPollChoices().get(1).getId()), + () -> assertThat(response.get(1).title()).isEqualTo(post.getPollChoices().get(1).getTitle()), + () -> assertThat(response.get(1).voteCount()).isEqualTo(1), + () -> assertThat(response.get(1).voteRatio()).isEqualTo("50") + ); + } + + @Test + @DisplayName("νˆ¬ν‘œ ν˜„ν™© 쑰회 - νˆ¬ν‘œν•œ μ‚¬λžŒμΈ 경우 νˆ¬ν‘œ ν˜„ν™©μ„ μ‘°νšŒν•  수 μžˆμ–΄μ•Ό 함") + void findVoteStatus_voteUser() { + //given + User author = userRepository.save(UserFixture.createDefaultUser()); + User voter = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(author.getId())); + voteRepository.save(VoteFixture.createDefaultVote(voter.getId(), post.getId(), post.getPollChoices().getFirst().getId())); + + //when + var response = voteService.findVoteResult(voter.getId(), post.getId()); + + //then + assertThat(response).isNotNull(); + } + + @Test + @DisplayName("νˆ¬ν‘œ ν˜„ν™© 쑰회 - μž‘μ„±μž μ•„λ‹ˆκ³  νˆ¬ν‘œ μ•ˆ ν•œ μ‚¬λžŒμΈ 경우") + void findVoteResult_notAuthorAndVoter() { + //given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + + //when + assertThatThrownBy(() -> voteService.findVoteResult(2L, post.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.ACCESS_DENIED_VOTE_STATUS.getMessage()); + } + +} diff --git a/src/test/java/com/chooz/vote/application/VoteValidatorTest.java b/src/test/java/com/chooz/vote/application/VoteValidatorTest.java new file mode 100644 index 00000000..b3c25663 --- /dev/null +++ b/src/test/java/com/chooz/vote/application/VoteValidatorTest.java @@ -0,0 +1,167 @@ +package com.chooz.vote.application; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.PollType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.Status; +import com.chooz.support.fixture.PostFixture; +import com.chooz.vote.domain.Vote; +import com.chooz.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class VoteValidatorTest { + + @InjectMocks + VoteValidator voteValidator; + + @Mock + VoteRepository voteRepository; + + @Mock + Clock clock; + + @Test + @DisplayName("νˆ¬ν‘œ μœ νš¨μ„± 검사") + void validateIsVotable() { + // given + Post post = PostFixture.createWithId(1L); + List pollChoiceIds = List.of(post.getPollChoices().get(0).getId()); + + // when & then + assertDoesNotThrow(() -> voteValidator.validateIsVotable(post, pollChoiceIds)); + } + + @Test + @DisplayName("validatePost - 이미 마감된 κ²Œμ‹œκΈ€μΈ 경우 μ˜ˆμ™Έ λ°œμƒ") + void validatePost_alreadyClosed() { + // given + Post post = PostFixture.createPostBuilder() + .status(Status.CLOSED) + .build(); + List pollChoiceIds = List.of(1L); + + // when & then + assertThatThrownBy(() -> voteValidator.validateIsVotable(post, pollChoiceIds)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); + } + + @Test + @DisplayName("validatePost - 마감 μ‹œκ°„μ΄ μ§€λ‚œ 경우 μ˜ˆμ™Έ λ°œμƒ") + void validatePost_closeDateOver() { + // given + Post post = PostFixture.createPostBuilder() + .closeOption(PostFixture.createCloseOptionOverDate()) + .build(); + List pollChoiceIds = List.of(1L); + + // when & then + assertThatThrownBy(() -> voteValidator.validateIsVotable(post, pollChoiceIds)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.CLOSE_DATE_OVER.getMessage()); + } + + @Test + @DisplayName("validatePost - νˆ¬ν‘œ μ°Έμ—¬μž μˆ˜κ°€ μ΄ˆκ³Όν•œ 경우 μ˜ˆμ™Έ λ°œμƒ") + void validatePost_exceedMaxVoterCount() { + // given + Post post = PostFixture.createPostBuilder() + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.VOTER) + .maxVoterCount(10) + .build() + ) + .build(); + given(voteRepository.countVoterByPostId(post.getId())) + .willReturn(10L); + List pollChoiceIds = List.of(1L); + + // when & then + assertThatThrownBy(() -> voteValidator.validateIsVotable(post, pollChoiceIds)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.EXCEED_MAX_VOTER_COUNT.getMessage()); + } + + @Test + @DisplayName("validateSingleVote - 단일 νˆ¬ν‘œμ— μ—¬λŸ¬ 선택지 μ§€μ • μ‹œ μ˜ˆμ™Έ λ°œμƒ") + void validateSingleVote_multipleChoices() { + // given + Post post = PostFixture.createPostBuilder() + .pollOption( + PostFixture.pollOptionBuilder() + .pollType(PollType.SINGLE) + .build() + ) + .build(); + List pollChoiceIds = List.of(1L, 2L); + + // when & then + assertThatThrownBy(() -> voteValidator.validateIsVotable(post, pollChoiceIds)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.SINGLE_POLL_ALLOWS_MAXIMUM_ONE_CHOICE.getMessage()); + } + + @Test + @DisplayName("validateMultipleVotes - 볡수 νˆ¬ν‘œμ— μ€‘λ³΅λœ 선택지 μ§€μ • μ‹œ μ˜ˆμ™Έ λ°œμƒ") + void validateMultipleVotes_duplicateChoices() { + // given + Post post = PostFixture.createPostBuilder() + .pollOption( + PostFixture.pollOptionBuilder() + .pollType(PollType.MULTIPLE) + .build() + ) + .build(); + Long duplicateId = 1L; + List pollChoiceIds = List.of(duplicateId, duplicateId); // μ€‘λ³΅λœ 선택지 + + // when & then + assertThatThrownBy(() -> voteValidator.validateIsVotable(post, pollChoiceIds)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DUPLICATE_POLL_CHOICE.getMessage()); + } + + @Test + @DisplayName("validatePollChoiceId - κ²Œμ‹œλ¬Όμ— μ—†λŠ” 선택지 ID μ§€μ • μ‹œ μ˜ˆμ™Έ λ°œμƒ") + void validatePollChoiceId_invalidChoiceId() { + // given + Post post = PostFixture.createDefaultPost(1L); + List pollChoiceIds = List.of(-1L); // μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 선택지 ID + + // when & then + assertThatThrownBy(() -> voteValidator.validateIsVotable(post, pollChoiceIds)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.NOT_POST_POLL_CHOICE_ID.getMessage()); + } + + @Test + @DisplayName("validateVoteStatusAccess - μž‘μ„±μžκ°€ μ•„λ‹ˆκ³  νˆ¬ν‘œν•˜μ§€ μ•Šμ€ μ‚¬μš©μžλŠ” νˆ¬ν‘œ ν˜„ν™© 쑰회 λΆˆκ°€") + void validateVoteResultAccess_notAuthorAndNotVoter() { + // given + Long userId = 999L; + Post post = PostFixture.createDefaultPost(1L); // μž‘μ„±μž ID: 1L + List votes = new ArrayList<>(); + + // when & then + assertThatThrownBy(() -> voteValidator.validateVoteResultAccess(userId, post, votes)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.ACCESS_DENIED_VOTE_STATUS.getMessage()); + } +} diff --git a/src/test/java/com/chooz/vote/domain/VoteRepositoryTest.java b/src/test/java/com/chooz/vote/domain/VoteRepositoryTest.java new file mode 100644 index 00000000..a6587c26 --- /dev/null +++ b/src/test/java/com/chooz/vote/domain/VoteRepositoryTest.java @@ -0,0 +1,73 @@ +package com.chooz.vote.domain; + +import com.chooz.post.domain.PollType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.support.RepositoryTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; + +class VoteRepositoryTest extends RepositoryTest { + + @Autowired + VoteRepository voteRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + UserRepository userRepository; + + @Test + @DisplayName("단일 νˆ¬ν‘œ μ°Έμ—¬μž 수 쑰회") + void countVoterByPostId_single() throws Exception { + //given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user1.getId())); + int voterCount = 5; + for (int i = 0; i < voterCount; i++) { + User user = userRepository.save(UserFixture.createDefaultUser()); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + + //when + long res = voteRepository.countVoterByPostId(post.getId()); + + //then + assertThat(res).isEqualTo(voterCount); + } + + @Test + @DisplayName("볡수 νˆ¬ν‘œ μ°Έμ—¬μž 수 쑰회") + void countVoterByPostId_multiple() throws Exception { + //given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user1.getId()) + .pollOption( + PostFixture.pollOptionBuilder() + .pollType(PollType.MULTIPLE) + .build()) + .build()); + int voterCount = 5; + for (int i = 0; i < voterCount; i++) { + User user = userRepository.save(UserFixture.createDefaultUser()); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(1).getId())); + } + + //when + long res = voteRepository.countVoterByPostId(post.getId()); + + //then + assertThat(res).isEqualTo(voterCount); + } +} diff --git a/src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java b/src/test/java/com/chooz/vote/presentation/VoteControllerTest.java similarity index 67% rename from src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java rename to src/test/java/com/chooz/vote/presentation/VoteControllerTest.java index f323ef9c..2e16f558 100644 --- a/src/test/java/com/swyp8team2/vote/presentation/VoteControllerTest.java +++ b/src/test/java/com/chooz/vote/presentation/VoteControllerTest.java @@ -1,9 +1,9 @@ -package com.swyp8team2.vote.presentation; +package com.chooz.vote.presentation; -import com.swyp8team2.vote.presentation.dto.PostImageVoteStatusResponse; -import com.swyp8team2.support.RestDocsTest; -import com.swyp8team2.support.WithMockUserInfo; -import com.swyp8team2.vote.presentation.dto.VoteRequest; +import com.chooz.vote.presentation.dto.VoteResultResponse; +import com.chooz.support.RestDocsTest; +import com.chooz.support.WithMockUserInfo; +import com.chooz.vote.presentation.dto.VoteRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; @@ -36,61 +36,42 @@ class VoteControllerTest extends RestDocsTest { @DisplayName("νˆ¬ν‘œ") void vote() throws Exception { //given - VoteRequest request = new VoteRequest(1L); + VoteRequest request = new VoteRequest(1L, List.of(1L)); //when test - mockMvc.perform(post("/posts/{postId}/votes", "1") + mockMvc.perform(post("/votes", "1") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .header(HttpHeaders.AUTHORIZATION, "Bearer token")) .andExpect(status().isOk()) .andDo(restDocs.document( requestHeaders(authorizationHeader()), - pathParameters( - parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") - ), requestFields( - fieldWithPath("imageId") + fieldWithPath("postId") .type(JsonFieldType.NUMBER) - .description("νˆ¬ν‘œ 후보 Id") + .description("κ²Œμ‹œκΈ€ Id"), + fieldWithPath("pollChoiceIds") + .type(JsonFieldType.ARRAY) + .description("νˆ¬ν‘œ 선택지 Id") ) )); verify(voteService, times(1)).vote(any(), any(), any()); } - @Test - @WithMockUserInfo - @DisplayName("νˆ¬ν‘œ μ·¨μ†Œ") - void cancelVote() throws Exception { - //given - - //when test - mockMvc.perform(delete("/votes/{voteId}", "1") - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - pathParameters( - parameterWithName("voteId").description("νˆ¬ν‘œ Id") - ) - )); - verify(voteService, times(1)).cancelVote(any(), any()); - } - @Test @WithMockUserInfo @DisplayName("κ²Œμ‹œκΈ€ νˆ¬ν‘œ μƒνƒœ 쑰회") - void findVoteStatus() throws Exception { + void findVoteResult() throws Exception { //given var response = List.of( - new PostImageVoteStatusResponse(1L, "λ½€λ˜A", 2, "66.7"), - new PostImageVoteStatusResponse(2L, "λ½€λ˜B", 1, "33.3") + new VoteResultResponse(1L, "title1", "http://example.com/image/1", 2, "67"), + new VoteResultResponse(2L, "title2", "http://example.com/image/2", 1, "33") ); - given(voteService.findVoteStatus(1L, 1L)) + given(voteService.findVoteResult(1L, 1L)) .willReturn(response); //when then - mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/{postId}/votes/status", 1) + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/{postId}/votes/result", 1) .header(HttpHeaders.AUTHORIZATION, "Bearer token")) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(response))) @@ -102,7 +83,8 @@ void findVoteStatus() throws Exception { responseFields( fieldWithPath("[]").type(JsonFieldType.ARRAY).description("νˆ¬ν‘œ 선택지 λͺ©λ‘"), fieldWithPath("[].id").type(JsonFieldType.NUMBER).description("이미지 Id"), - fieldWithPath("[].imageName").type(JsonFieldType.STRING).description("사진 이름"), + fieldWithPath("[].title").type(JsonFieldType.STRING).description("사진 이름"), + fieldWithPath("[].imageUrl").type(JsonFieldType.STRING).description("사진 URL"), fieldWithPath("[].voteCount").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ 수"), fieldWithPath("[].voteRatio").type(JsonFieldType.STRING).description("νˆ¬ν‘œ λΉ„μœ¨") ) diff --git a/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java b/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java deleted file mode 100644 index 4649e88e..00000000 --- a/src/test/java/com/swyp8team2/comment/application/CommentServiceTest.java +++ /dev/null @@ -1,232 +0,0 @@ -package com.swyp8team2.comment.application; - -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.comment.domain.Comment; -import com.swyp8team2.comment.domain.CommentRepository; -import com.swyp8team2.comment.presentation.dto.CommentResponse; -import com.swyp8team2.comment.presentation.dto.CommentRequest; -import com.swyp8team2.common.dto.CursorBasePaginatedResponse; -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.common.exception.ForbiddenException; -import com.swyp8team2.user.domain.Role; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import com.swyp8team2.vote.domain.Vote; -import com.swyp8team2.vote.domain.VoteRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.SliceImpl; - -import java.util.List; -import java.util.Optional; - -import static java.util.Optional.empty; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class CommentServiceTest { - - @Mock - private CommentRepository commentRepository; - - @Mock - private UserRepository userRepository; - - @InjectMocks - private CommentService commentService; - - @Mock - private VoteRepository voteRepository; - - @Test - @DisplayName("λŒ“κΈ€ 생성") - void createComment() { - // given - Long postId = 1L; - CommentRequest request = new CommentRequest("ν…ŒμŠ€νŠΈ λŒ“κΈ€"); - UserInfo userInfo = new UserInfo(100L, Role.USER); - Comment comment = new Comment(postId, userInfo.userId(), request.content()); - - // when - when(commentRepository.save(any(Comment.class))).thenReturn(comment); - - // then - assertDoesNotThrow(() -> commentService.createComment(postId, request, userInfo)); - } - - @Test - @DisplayName("λŒ“κΈ€ 쑰회") - void findComments() { - // given - Long postId = 1L; - Long cursor = null; - int size = 2; - - Comment comment1 = new Comment(1L, postId, 100L, "첫 번째 λŒ“κΈ€"); - Comment comment2 = new Comment(2L, postId, 100L, "두 번째 λŒ“κΈ€"); - SliceImpl commentSlice = new SliceImpl<>(List.of(comment1, comment2), PageRequest.of(0, size), false); - User user = new User(100L, "λ‹‰λ„€μž„","http://example.com/profile.png", Role.USER); - List votes = List.of( - Vote.of(1L, 100L, 1L), - Vote.of(1L, 101L, 1L) - ); - - // Mock μ„€μ • - given(commentRepository.findByPostId(eq(postId), eq(cursor), any(PageRequest.class))).willReturn(commentSlice); - given(voteRepository.findByUserIdAndPostId(eq(user.getId()), eq(postId))).willReturn(votes); - // 각 λŒ“κΈ€λ§ˆλ‹€ user_no=100L μ΄λ―€λ‘œ, findById(100L)만 호좜됨 - given(userRepository.findById(100L)).willReturn(Optional.of(user)); - - // when - CursorBasePaginatedResponse response = commentService.findComments(user.getId(), postId, cursor, size); - - // then - assertThat(response.data()).hasSize(2); - - CommentResponse cr1 = response.data().get(0); - assertThat(cr1.commentId()).isEqualTo(1L); - assertThat(cr1.content()).isEqualTo("첫 번째 λŒ“κΈ€"); - assertThat(cr1.author().nickname()).isEqualTo("λ‹‰λ„€μž„"); - - CommentResponse cr2 = response.data().get(1); - assertThat(cr2.commentId()).isEqualTo(2L); - assertThat(cr2.content()).isEqualTo("두 번째 λŒ“κΈ€"); - } - - @Test - @DisplayName("λŒ“κΈ€ 쑰회 - μœ μ € 정보 μ—†λŠ” 경우") - void findComments_userNotFound() { - // given - Long postId = 1L; - Long cursor = null; - int size = 2; - - Comment comment1 = new Comment(1L, postId, 100L, "첫 번째 λŒ“κΈ€"); - Comment comment2 = new Comment(2L, postId, 100L, "두 번째 λŒ“κΈ€"); - SliceImpl commentSlice = new SliceImpl<>( - List.of(comment1, comment2), - PageRequest.of(0, size), - false - ); - - given(commentRepository.findByPostId(eq(postId), eq(cursor), any(PageRequest.class))).willReturn(commentSlice); - given(userRepository.findById(100L)).willReturn(empty()); - - // when & then - assertThatThrownBy(() -> commentService.findComments(1L, postId, cursor, size)) - .isInstanceOf(BadRequestException.class) - .hasMessage((ErrorCode.USER_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("λŒ“κΈ€ μˆ˜μ •") - void updateComment() { - // given - Long postId = 1L; - UserInfo userInfo = new UserInfo(100L, Role.USER); - Comment comment = new Comment(1L, postId, 100L, "첫 번째 λŒ“κΈ€"); - CommentRequest request = new CommentRequest("μˆ˜μ • λŒ“κΈ€"); - when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.of(comment)); - - // when - commentService.updateComment(1L, request, userInfo); - - // then - assertAll( - () -> assertThat(comment.getId()).isEqualTo(1L), - () -> assertThat(comment.getContent()).isEqualTo("μˆ˜μ • λŒ“κΈ€") - ); - } - - @Test - @DisplayName("λŒ“κΈ€ μˆ˜μ • - μ‘΄μž¬ν•˜μ§€ μ•Šμ€ λŒ“κΈ€") - void updateComment_commentNotFound() { - // given - CommentRequest request = new CommentRequest("μˆ˜μ • λŒ“κΈ€"); - UserInfo userInfo = new UserInfo(100L, Role.USER); - when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.empty()); - - // when then - assertThatThrownBy(() -> commentService.updateComment(1L, request, userInfo)) - .isInstanceOf(BadRequestException.class) - .hasMessage((ErrorCode.COMMENT_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("λŒ“κΈ€ μˆ˜μ • - κΆŒν•œ μ—†λŠ” μ‚¬μš©μž") - void updateComment_forbiddenException() { - // given - Long postId = 1L; - UserInfo userInfo = new UserInfo(100L, Role.USER); - Comment comment = new Comment(1L, postId, 110L, "첫 번째 λŒ“κΈ€"); - CommentRequest request = new CommentRequest("μˆ˜μ • λŒ“κΈ€"); - when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.of(comment)); - - // when then - assertAll( - () -> assertThatThrownBy(() -> commentService.updateComment(1L, request, userInfo)) - .isInstanceOf(ForbiddenException.class), - () -> assertThat(comment.getContent()).isEqualTo("첫 번째 λŒ“κΈ€") - ); - } - - @Test - @DisplayName("λŒ“κΈ€ μ‚­μ œ") - void deleteComment() { - // given - Long postId = 1L; - UserInfo userInfo = new UserInfo(100L, Role.USER); - Comment comment = new Comment(1L, postId, 100L, "첫 번째 λŒ“κΈ€"); - when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.of(comment)); - - // when - commentService.deleteComment(1L, userInfo); - - // then - assertAll( - () -> assertTrue(comment.isDeleted()), - () -> assertNotNull(comment.getDeletedAt()) - ); - } - - @Test - @DisplayName("λŒ“κΈ€ μ‚­μ œ - μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λŒ“κΈ€") - void deleteComment_commentNotFound() { - // given - UserInfo userInfo = new UserInfo(100L, Role.USER); - when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.empty()); - - // when then - assertThatThrownBy(() -> commentService.deleteComment(1L, userInfo)) - .isInstanceOf(BadRequestException.class) - .hasMessage((ErrorCode.COMMENT_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("λŒ“κΈ€ μ‚­μ œ - κΆŒν•œ μ—†λŠ” μ‚¬μš©μž") - void deleteComment_forbiddenException() { - // given - Long postId = 1L; - UserInfo userInfo = new UserInfo(100L, Role.USER); - Comment comment = new Comment(1L, postId, 110L, "첫 번째 λŒ“κΈ€"); - when(commentRepository.findByIdAndNotDeleted(1L)).thenReturn(Optional.of(comment)); - - // when then - assertThatThrownBy(() -> commentService.deleteComment(1L, userInfo)) - .isInstanceOf(ForbiddenException.class); - assertFalse(comment.isDeleted()); - assertNull(comment.getDeletedAt()); - } -} diff --git a/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java b/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java deleted file mode 100644 index d4e3a366..00000000 --- a/src/test/java/com/swyp8team2/comment/domain/CommentRepositoryTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.swyp8team2.comment.domain; - -import com.swyp8team2.support.RepositoryTest; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class CommentRepositoryTest extends RepositoryTest { - - @Autowired - private CommentRepository commentRepository; - - @Test - @DisplayName("λŒ“κΈ€ 쑰회") - void select_CommentUser() { - // given - Comment comment1 = new Comment(1L, 100L, "content1"); - Comment comment2 = new Comment(1L, 101L, "content2"); - Comment comment3 = new Comment(1L, 102L, "content3"); - commentRepository.saveAll(List.of(comment1, comment2, comment3)); - - // when - Slice result1 = commentRepository.findByPostId(1L, null, PageRequest.of(0, 10)); - - // then - assertThat(result1.getContent()).hasSize(3); - } - - @Test - @DisplayName("λŒ“κΈ€ 쑰회 - 단일 쑰회") - void select_CommentById() { - // given - Comment comment = new Comment(1L, 100L, "content"); - commentRepository.save(comment); - - // when - Comment selectComment = commentRepository.findByIdAndNotDeleted(1L) - .orElse(new Comment(2L, 2L, 101L, "content")); - - // then - assertThat(comment.getId()).isEqualTo(selectComment.getId()); - } -} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/comment/presentation/CommentControllerTest.java b/src/test/java/com/swyp8team2/comment/presentation/CommentControllerTest.java deleted file mode 100644 index 0eda57c8..00000000 --- a/src/test/java/com/swyp8team2/comment/presentation/CommentControllerTest.java +++ /dev/null @@ -1,189 +0,0 @@ -package com.swyp8team2.comment.presentation; - -import com.swyp8team2.auth.domain.UserInfo; -import com.swyp8team2.comment.presentation.dto.AuthorDto; -import com.swyp8team2.comment.presentation.dto.CommentResponse; -import com.swyp8team2.comment.presentation.dto.CommentRequest; -import com.swyp8team2.common.dto.CursorBasePaginatedResponse; -import com.swyp8team2.support.RestDocsTest; -import com.swyp8team2.support.WithMockUserInfo; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.security.test.context.support.WithAnonymousUser; - -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.*; -import static org.springframework.restdocs.request.RequestDocumentation.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class CommentControllerTest extends RestDocsTest { - - @Test - @WithMockUserInfo - @DisplayName("λŒ“κΈ€ 생성") - void createComment() throws Exception { - //given - Long postId = 1L; - CommentRequest request = new CommentRequest("content"); - - doNothing().when(commentService).createComment(eq(postId), any(CommentRequest.class), any(UserInfo.class)); - - //when then - mockMvc.perform(post("/posts/{postId}/comments", "1") - .header(HttpHeaders.AUTHORIZATION, "Bearer token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - pathParameters( - parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") - ), - requestFields( - fieldWithPath("content").type(JsonFieldType.STRING).description("λŒ“κΈ€ λ‚΄μš©").attributes(constraints("μ΅œλŒ€ ?κΈ€μž")) - ) - )); - - verify(commentService, times(1)).createComment(eq(postId), any(CommentRequest.class), any(UserInfo.class)); - } - - @Test - @WithAnonymousUser - @DisplayName("λŒ“κΈ€ 쑰회") - void findComments() throws Exception { - //given - Long postId = 1L; - Long cursor = null; - int size = 10; - CommentResponse commentResponse = new CommentResponse( - 1L, - "λŒ“κΈ€ λ‚΄μš©", - new AuthorDto(100L, "λ‹‰λ„€μž„", "http://example.com/profile.png"), - List.of(1L, 2L), - LocalDateTime.now(), - false - ); - List commentList = Collections.singletonList(commentResponse); - - CursorBasePaginatedResponse response = - new CursorBasePaginatedResponse<>(null, false, commentList); - - when(commentService.findComments(eq(null), eq(postId), eq(cursor), eq(size))).thenReturn(response); - - //when - mockMvc.perform(get("/posts/{postId}/comments", "1")) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andDo(restDocs.document( - pathParameters( - parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") - ), - queryParameters(cursorQueryParams()), - responseFields( - fieldWithPath("nextCursor") - .type(JsonFieldType.NUMBER) - .optional() - .description("λ‹€μŒ 쑰회 μ»€μ„œ κ°’"), - fieldWithPath("hasNext") - .type(JsonFieldType.BOOLEAN) - .description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€ (κΈ°λ³Έ κ°’ 10)"), - fieldWithPath("data[]") - .type(JsonFieldType.ARRAY) - .description("λŒ“κΈ€ 데이터"), - fieldWithPath("data[].commentId") - .type(JsonFieldType.NUMBER) - .description("λŒ“κΈ€ Id"), - fieldWithPath("data[].content") - .type(JsonFieldType.STRING) - .description("λŒ“κΈ€ λ‚΄μš©"), - fieldWithPath("data[].author") - .type(JsonFieldType.OBJECT) - .description("μž‘μ„±μž"), - fieldWithPath("data[].author.userId") - .type(JsonFieldType.NUMBER) - .description("μž‘μ„±μž id"), - fieldWithPath("data[].author.nickname") - .type(JsonFieldType.STRING) - .description("μž‘μ„±μž λ‹‰λ„€μž„"), - fieldWithPath("data[].author.profileUrl") - .type(JsonFieldType.STRING) - .description("μž‘μ„±μž ν”„λ‘œν•„ 이미지 url"), - fieldWithPath("data[].voteImageId[]") - .type(JsonFieldType.ARRAY) - .optional() - .description("μž‘μ„±μžκ°€ νˆ¬ν‘œν•œ 이미지 Id (νˆ¬ν‘œ 없을 μ‹œ null)"), - fieldWithPath("data[].createdAt") - .type(JsonFieldType.STRING) - .description("λŒ“κΈ€ μž‘μ„±μΌ"), - fieldWithPath("data[].isAuthor") - .type(JsonFieldType.BOOLEAN) - .description("μž‘μ„±μž μ—¬λΆ€") - ) - )); - - verify(commentService, times(1)).findComments(eq(null), eq(postId), eq(cursor), eq(size)); - } - - @Test - @WithMockUserInfo - @DisplayName("λŒ“κΈ€ μˆ˜μ •") - void updateComment() throws Exception { - //given - CommentRequest request = new CommentRequest("μˆ˜μ • λŒ“κΈ€"); - - //when then - mockMvc.perform(post("/posts/{postId}/comments/{commentId}", "1", "1") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - pathParameters( - parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id"), - parameterWithName("commentId").description("λŒ“κΈ€ Id") - ), - requestFields( - fieldWithPath("content") - .type(JsonFieldType.STRING) - .description("λŒ“κΈ€ λ‚΄μš©") - .attributes(constraints("μ΅œλŒ€ ?κΈ€μž")) - ) - )); - } - - @Test - @WithMockUserInfo - @DisplayName("λŒ“κΈ€ μ‚­μ œ") - void deleteComment() throws Exception { - //given - Long commentId = 1L; - doNothing().when(commentService).deleteComment(eq(commentId), any(UserInfo.class)); - - //when then - mockMvc.perform(delete("/posts/{postId}/comments/{commentId}", "1", "1") - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - pathParameters( - parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id"), - parameterWithName("commentId").description("λŒ“κΈ€ Id") - ) - )); - - verify(commentService, times(1)).deleteComment(eq(commentId), any(UserInfo.class)); - } -} diff --git a/src/test/java/com/swyp8team2/image/application/ImageServiceTest.java b/src/test/java/com/swyp8team2/image/application/ImageServiceTest.java deleted file mode 100644 index c38daacf..00000000 --- a/src/test/java/com/swyp8team2/image/application/ImageServiceTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.swyp8team2.image.application; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.image.domain.ImageFile; -import com.swyp8team2.image.domain.ImageFileRepository; -import com.swyp8team2.image.presentation.dto.ImageFileDto; -import com.swyp8team2.image.presentation.dto.ImageFileResponse; -import com.swyp8team2.image.util.FileValidator; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.util.ReflectionTestUtils; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.concurrent.atomic.AtomicLong; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -public class ImageServiceTest { - - @Mock - private R2Storage r2Storage; - - @Mock - private FileValidator fileValidator; - - @Mock - private ImageFileRepository imageFileRepository; - - @InjectMocks - private ImageService imageService; - - @Test - @DisplayName("ImageFile Entity 생성") - void createImageFile() { - // given - ImageFileDto dto = new ImageFileDto("test.jpg", "https://image.photopic.site/test.jpg", "https://image.photopic.site/thumb.jpg"); - ImageFile imageFile = ImageFile.create(dto); - - // when - ReflectionTestUtils.setField(imageFile, "id", 100L); - when(imageFileRepository.save(any(ImageFile.class))).thenReturn(imageFile); - Long id = imageService.createImageFile(dto); - - // then - assertEquals(100L, id); - } - - @Test - @DisplayName("ImageFile Entity 생성 - νŒŒλΌλ―Έν„°κ°€ null인 경우") - void createImageFile_null() { - // given - ImageFileDto dto = new ImageFileDto("test.jpg", null, null); - - // when - when(imageFileRepository.save(any(ImageFile.class))) - .thenThrow(new BadRequestException(ErrorCode.INVALID_ARGUMENT)); - - // then - assertThatThrownBy(() -> imageService.createImageFile(dto)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.INVALID_ARGUMENT.getMessage()); - - } - - @Test - @DisplayName("ImageFile Entity 생성 - νŒŒλΌλ―Έν„°κ°€ 빈 값인 경우") - void createImageFile_emptyString() { - // given - ImageFileDto dto = new ImageFileDto("test.jpg", "", ""); - - // when - when(imageFileRepository.save(any(ImageFile.class))) - .thenThrow(new BadRequestException(ErrorCode.INVALID_ARGUMENT)); - - // then - assertThatThrownBy(() -> imageService.createImageFile(dto)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.INVALID_ARGUMENT.getMessage()); - - } - - @Test - @DisplayName("파일 μ—…λ‘œλ“œ") - void uploadImageFile() { - // given - MockMultipartFile file1 = new MockMultipartFile( - "files", - "test1.jpg", - MediaType.IMAGE_JPEG_VALUE, - "dummy content".getBytes(StandardCharsets.UTF_8) - ); - MockMultipartFile file2 = new MockMultipartFile( - "files", - "test2.png", - MediaType.IMAGE_PNG_VALUE, - "dummy content".getBytes(StandardCharsets.UTF_8) - ); - - List imageFiles = List.of( - new ImageFileDto("test1.jpg", "https://image.photopic.site/test1.jpg", "https://image.photopic.site/thumb1.jpg"), - new ImageFileDto("test2.png", "https://image.photopic.site/test2.png", "https://image.photopic.site/thumb2.png") - ); - - doNothing().when(fileValidator).validate(file1, file2); - when(r2Storage.uploadImageFile(file1, file2)).thenReturn(imageFiles); - - AtomicLong idGenerator = new AtomicLong(1L); - when(imageFileRepository.save(any(ImageFile.class))).thenAnswer(invocation -> new ImageFile() { - @Override - public Long getId() { - return idGenerator.getAndIncrement(); - } - }); - - // when - ImageFileResponse response = imageService.uploadImageFile(file1, file2); - - // then - assertAll( - () -> assertNotNull(response), - () -> assertEquals(2, response.imageFileId().size()), - () -> assertThat(response.imageFileId().get(0)).isEqualTo(1L), - () -> assertThat(response.imageFileId().get(1)).isEqualTo(2L) - ); - } - - @Test - @DisplayName("파일 μ—…λ‘œλ“œ - IOException λ°œμƒ") - void uploadImageFile_IOException() { - // given - MockMultipartFile file1 = new MockMultipartFile( - "files", - "test1.jpg", - MediaType.IMAGE_JPEG_VALUE, - "dummy content".getBytes(StandardCharsets.UTF_8) - ); - MockMultipartFile file2 = new MockMultipartFile( - "files", - "test2.png", - MediaType.IMAGE_PNG_VALUE, - "dummy content".getBytes(StandardCharsets.UTF_8) - ); - - doNothing().when(fileValidator).validate(file1, file2); - when(r2Storage.uploadImageFile(file1, file2)) - .thenThrow(new UncheckedIOException(new IOException(ErrorCode.SERVICE_UNAVAILABLE.getMessage()))); - - // when then - assertThatThrownBy(() -> imageService.uploadImageFile(file1, file2)) - .isInstanceOf(UncheckedIOException.class) - .hasMessageContaining(ErrorCode.SERVICE_UNAVAILABLE.getMessage()); - } -} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/image/presentation/ImageControllerTest.java b/src/test/java/com/swyp8team2/image/presentation/ImageControllerTest.java deleted file mode 100644 index 37c8b636..00000000 --- a/src/test/java/com/swyp8team2/image/presentation/ImageControllerTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.swyp8team2.image.presentation; - -import com.swyp8team2.image.presentation.dto.ImageFileResponse; -import com.swyp8team2.support.RestDocsTest; -import com.swyp8team2.support.WithMockUserInfo; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - -import java.nio.charset.StandardCharsets; -import java.util.List; - -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.partWithName; -import static org.springframework.restdocs.request.RequestDocumentation.requestParts; -import static org.springframework.restdocs.snippet.Attributes.key; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class ImageControllerTest extends RestDocsTest { - - @Test - @WithMockUserInfo - @DisplayName("이미지 μ—…λ‘œλ“œ") - void createImageFile() throws Exception { - //given - MockMultipartFile file1 = new MockMultipartFile( - "files", - "image.jpg", - MediaType.IMAGE_JPEG_VALUE, - "".getBytes(StandardCharsets.UTF_8) - ); - MockMultipartFile file2 = new MockMultipartFile( - "files", - "image.png", - MediaType.IMAGE_PNG_VALUE, - "".getBytes(StandardCharsets.UTF_8) - ); - ImageFileResponse response = new ImageFileResponse(List.of(1L, 2L)); - - // stub - when(imageService.uploadImageFile(file1, file2)).thenReturn(response); - - //when then - mockMvc.perform(MockMvcRequestBuilders.multipart("/image/upload") - .file(file1) - .file(file2) - .contentType(MediaType.MULTIPART_FORM_DATA) - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - requestParts( - partWithName("files") - .description("νˆ¬ν‘œ 후보 이미지 파일") - .attributes(key("type").value("Array[File]")) - .attributes(constraints("μ΅œμ†Œ 2개")) - ), - responseFields( - fieldWithPath("imageFileId") - .description("μ—…λ‘œλ“œλœ 이미지 파일") - .type(JsonFieldType.ARRAY) - ) - )); - - } -} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java b/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java deleted file mode 100644 index 17aecc86..00000000 --- a/src/test/java/com/swyp8team2/image/util/FileValidatorTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.swyp8team2.image.util; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; - -import java.nio.charset.StandardCharsets; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class FileValidatorTest { - - private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - - private FileValidator fileValidator; - - @BeforeEach - void setUp() { - String allowedExtensions = "gif,jpg,jpeg,png,webp,heic,heif"; - fileValidator = new FileValidator(allowedExtensions); - } - - @Test - @DisplayName("파일 μœ νš¨μ„± 체크 - 파일 크기 초과") - void validate_exceedMaxFileSize() { - // given - byte[] largeContent = new byte[(int) (MAX_FILE_SIZE + 1)]; - MockMultipartFile file = new MockMultipartFile( - "file", - "test.jpg", - MediaType.IMAGE_JPEG_VALUE, - largeContent - ); - - // when then - assertThatThrownBy(() -> fileValidator.validate(file)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.EXCEED_MAX_FILE_SIZE.getMessage()); - } - - @Test - @DisplayName("파일 μœ νš¨μ„± 체크 - μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν™•μž₯자") - void validate_unsupportedFileExtension() { - // given - MockMultipartFile file = new MockMultipartFile( - "file", - "test.txt", - "text/plain", - "dummy content".getBytes(StandardCharsets.UTF_8) - ); - - // when then - assertThatThrownBy(() -> fileValidator.validate(file)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.UNSUPPORTED_FILE_EXTENSION.getMessage()); - } - - @Test - @DisplayName("파일 μœ νš¨μ„± 체크 - 파일λͺ… λ„ˆλ¬΄ κΉ€") - void validate_fileNameTooLong() { - // given - String filename = new String(new char[101])+".jpeg"; - MockMultipartFile file = new MockMultipartFile( - "file", - filename, - "", - "dummy content".getBytes(StandardCharsets.UTF_8) - ); - - // when then - assertThatThrownBy(() -> fileValidator.validate(file)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.FILE_NAME_TOO_LONG.getMessage()); - } - - @Test - @DisplayName("파일 μœ νš¨μ„± 체크 - ν™•μž₯자 λˆ„λ½") - void validate_missingFileExtension() { - // given - MockMultipartFile file = new MockMultipartFile( - "file", - "test", - "text/plain", - "dummy content".getBytes(StandardCharsets.UTF_8) - ); - - // when then - assertThatThrownBy(() -> fileValidator.validate(file)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.MISSING_FILE_EXTENSION.getMessage()); - } - - @Test - @DisplayName("파일 μœ νš¨μ„± 체크 - μ—¬λŸ¬ 파일 쀑 ν•˜λ‚˜κ°€ μœ νš¨μ„± μ‹€νŒ¨") - void validate_multipleFilesOneInvalid() { - // given - MockMultipartFile file1 = new MockMultipartFile( - "file", - "test.jpg", - "image/jpeg", - "dummy".getBytes(StandardCharsets.UTF_8) - ); - byte[] largeContent = new byte[(int) (MAX_FILE_SIZE + 1)]; - MockMultipartFile file2 = new MockMultipartFile( - "file", - "large.jpg", - "image/jpeg", - largeContent - ); - - // when then - assertThatThrownBy(() -> fileValidator.validate(file1, file2)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.EXCEED_MAX_FILE_SIZE.getMessage()); - } -} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/post/application/PostCommandServiceTest.java b/src/test/java/com/swyp8team2/post/application/PostCommandServiceTest.java deleted file mode 100644 index 3e523419..00000000 --- a/src/test/java/com/swyp8team2/post/application/PostCommandServiceTest.java +++ /dev/null @@ -1,213 +0,0 @@ -package com.swyp8team2.post.application; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.image.domain.ImageFile; -import com.swyp8team2.image.domain.ImageFileRepository; -import com.swyp8team2.post.domain.*; -import com.swyp8team2.post.presentation.dto.CreatePostRequest; -import com.swyp8team2.post.presentation.dto.CreatePostResponse; -import com.swyp8team2.post.presentation.dto.PostImageRequestDto; -import com.swyp8team2.support.IntegrationTest; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -import java.util.ArrayList; -import java.util.List; - -import static com.swyp8team2.support.fixture.FixtureGenerator.createImageFile; -import static com.swyp8team2.support.fixture.FixtureGenerator.createPost; -import static com.swyp8team2.support.fixture.FixtureGenerator.createUser; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; - -public class PostCommandServiceTest extends IntegrationTest { - - @Autowired - PostService postService; - - @Autowired - PostRepository postRepository; - - @Autowired - UserRepository userRepository; - - @Autowired - ImageFileRepository imageFileRepository; - - @MockitoBean - ShareUrlService shareUrlShareUrlService; - - @Test - @DisplayName("κ²Œμ‹œκΈ€ μž‘μ„±") - void create() throws Exception { - //given - long userId = 1L; - CreatePostRequest request = new CreatePostRequest( - "description", - List.of( - new PostImageRequestDto(1L), - new PostImageRequestDto(2L) - ), - Scope.PRIVATE, - VoteType.SINGLE - ); - String shareUrl = "shareUrl"; - given(shareUrlShareUrlService.encrypt(any())) - .willReturn(shareUrl); - - //when - CreatePostResponse response = postService.create(userId, request); - - //then - Post post = postRepository.findById(response.postId()).get(); - List images = post.getImages(); - assertAll( - () -> assertThat(post.getDescription()).isEqualTo("description"), - () -> assertThat(post.getUserId()).isEqualTo(userId), - () -> assertThat(post.getShareUrl()).isEqualTo(shareUrl), - () -> assertThat(post.getStatus()).isEqualTo(Status.PROGRESS), - () -> assertThat(post.getVoteType()).isEqualTo(VoteType.SINGLE), - () -> assertThat(images).hasSize(2), - () -> assertThat(images.get(0).getImageFileId()).isEqualTo(1L), - () -> assertThat(images.get(0).getName()).isEqualTo("λ½€λ˜A"), - () -> assertThat(images.get(0).getVoteCount()).isEqualTo(0), - () -> assertThat(images.get(1).getImageFileId()).isEqualTo(2L), - () -> assertThat(images.get(1).getName()).isEqualTo("λ½€λ˜B"), - () -> assertThat(images.get(1).getVoteCount()).isEqualTo(0) - ); - } - - @Test - @DisplayName("κ²Œμ‹œκΈ€ μž‘μ„± - 이미지가 2개 미만인 경우") - void create_invalidPostImageCount() throws Exception { - //given - long userId = 1L; - CreatePostRequest request = new CreatePostRequest( - "description", - List.of( - new PostImageRequestDto(1L) - ), - Scope.PRIVATE, - VoteType.SINGLE - ); - //when then - assertThatThrownBy(() -> postService.create(userId, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.INVALID_POST_IMAGE_COUNT.getMessage()); - } - - @Test - @DisplayName("κ²Œμ‹œκΈ€ μž‘μ„± - μ„€λͺ…이 100자 λ„˜μ–΄κ°€λŠ” 경우") - void create_descriptionCountExceeded() throws Exception { - //given - long userId = 1L; - CreatePostRequest request = new CreatePostRequest( - "a".repeat(101), - List.of( - new PostImageRequestDto(1L), - new PostImageRequestDto(2L) - ), - Scope.PRIVATE, - VoteType.SINGLE - ); - - //when then - assertThatThrownBy(() -> postService.create(userId, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED.getMessage()); - } - - @Test - @DisplayName("νˆ¬ν‘œ 마감") - void close() throws Exception { - //given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - - //when - post.close(user.getId()); - - //then - postRepository.findById(post.getId()).get(); - assertThat(post.getStatus()).isEqualTo(Status.CLOSED); - } - - @Test - @DisplayName("νˆ¬ν‘œ 마감 - κ²Œμ‹œκΈ€ μž‘μ„±μžκ°€ 아닐 경우") - void close_notPostAuthor() throws Exception { - //given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - - //when then - assertThatThrownBy(() -> post.close(2L)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); - } - - @Test - @DisplayName("νˆ¬ν‘œ 마감 - 이미 마감된 κ²Œμ‹œκΈ€μΈ 경우") - void close_alreadyClosed() throws Exception { - //given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - post.close(user.getId()); - - //when then - assertThatThrownBy(() -> post.close(user.getId())) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); - } - - @Test - @DisplayName("νˆ¬ν‘œ 마감 - μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” κ²Œμ‹œκΈ€μΌ 경우") - void close_notFoundPost() throws Exception { - //given - - //when then - assertThatThrownBy(() -> postService.close(1L, 1L)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); - } - - @Test - @DisplayName("κ²Œμ‹œκΈ€ μ‚­μ œ") - void delete() throws Exception { - //given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - - //when - postService.delete(user.getId(), post.getId()); - - //then - assertThat(postRepository.findById(post.getId())).isEmpty(); - } - - private List createPosts(User user) { - List posts = new ArrayList<>(); - for (int i = 0; i < 30; i += 2) { - ImageFile imageFile1 = imageFileRepository.save(createImageFile(i)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(i + 1)); - posts.add(postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, i))); - } - return posts; - } - -} diff --git a/src/test/java/com/swyp8team2/post/application/PostQueryServiceTest.java b/src/test/java/com/swyp8team2/post/application/PostQueryServiceTest.java deleted file mode 100644 index 81569469..00000000 --- a/src/test/java/com/swyp8team2/post/application/PostQueryServiceTest.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.swyp8team2.post.application; - -import com.swyp8team2.comment.domain.Comment; -import com.swyp8team2.comment.domain.CommentRepository; -import com.swyp8team2.common.dto.CursorBasePaginatedResponse; -import com.swyp8team2.image.domain.ImageFile; -import com.swyp8team2.image.domain.ImageFileRepository; -import com.swyp8team2.post.domain.Post; -import com.swyp8team2.post.domain.PostRepository; -import com.swyp8team2.post.domain.Scope; -import com.swyp8team2.post.presentation.dto.FeedResponse; -import com.swyp8team2.post.presentation.dto.PostImageResponse; -import com.swyp8team2.post.presentation.dto.PostResponse; -import com.swyp8team2.support.IntegrationTest; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import com.swyp8team2.vote.domain.Vote; -import com.swyp8team2.vote.domain.VoteRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -import java.util.ArrayList; -import java.util.List; - -import static com.swyp8team2.support.fixture.FixtureGenerator.*; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -class PostQueryServiceTest extends IntegrationTest { - - @Autowired - PostService postService; - - @Autowired - PostRepository postRepository; - - @Autowired - UserRepository userRepository; - - @Autowired - ImageFileRepository imageFileRepository; - - @Autowired - VoteRepository voteRepository; - - @Autowired - CommentRepository commentRepository; - - @Test - @DisplayName("κ²Œμ‹œκΈ€ 쑰회") - void findById() throws Exception { - //given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - - //when - PostResponse response = postService.findById(user.getId(), post.getId()); - - //then - List votes = response.images(); - assertAll( - () -> assertThat(response.description()).isEqualTo(post.getDescription()), - () -> assertThat(response.id()).isEqualTo(post.getId()), - () -> assertThat(response.author().nickname()).isEqualTo(user.getNickname()), - () -> assertThat(response.author().profileUrl()).isEqualTo(user.getProfileUrl()), - () -> assertThat(response.shareUrl()).isEqualTo(post.getShareUrl()), - () -> assertThat(votes).hasSize(2), - () -> assertThat(votes.get(0).imageUrl()).isEqualTo(imageFile1.getImageUrl()), - () -> assertThat(votes.get(0).voteId()).isNull(), - () -> assertThat(votes.get(1).imageUrl()).isEqualTo(imageFile2.getImageUrl()), - () -> assertThat(votes.get(1).voteId()).isNull() - ); - } - - @Test - @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - μ»€μ„œ null인 경우") - void findUserPosts() throws Exception { - //given - User user = userRepository.save(createUser(1)); - List posts = createPosts(user, Scope.PRIVATE); - int size = 10; - - //when - var response = postService.findUserPosts(user.getId(), null, size); - - //then - assertAll( - () -> assertThat(response.data()).hasSize(size), - () -> assertThat(response.hasNext()).isTrue(), - () -> assertThat(response.nextCursor()).isEqualTo(posts.get(posts.size() - size).getId()) - ); - } - - @Test - @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - μ»€μ„œ μžˆλŠ” 경우") - void findUserPosts2() throws Exception { - //given - User user = userRepository.save(createUser(1)); - List posts = createPosts(user, Scope.PRIVATE); - int size = 10; - - //when - var response = postService.findUserPosts(user.getId(), posts.get(3).getId(), size); - - //then - assertAll( - () -> assertThat(response.data()).hasSize(3), - () -> assertThat(response.hasNext()).isFalse(), - () -> assertThat(response.nextCursor()).isEqualTo(posts.get(0).getId()) - ); - } - - private List createPosts(User user, Scope scope) { - List posts = new ArrayList<>(); - for (int i = 0; i < 30; i += 2) { - ImageFile imageFile1 = imageFileRepository.save(createImageFile(i)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(i + 1)); - posts.add(postRepository.save(createPost(user.getId(), scope, imageFile1, imageFile2, i))); - } - return posts; - } - - @Test - @DisplayName("μœ μ €κ°€ νˆ¬ν‘œν•œ κ²Œμ‹œκΈ€ 쑰회 - μ»€μ„œ null인 경우") - void findVotedPosts() throws Exception { - //given - User user = userRepository.save(createUser(1)); - List posts = createPosts(user, Scope.PRIVATE); - for (int i = 0; i < 15; i++) { - Post post = posts.get(i); - voteRepository.save(Vote.of(post.getId(), post.getImages().get(0).getId(), user.getId())); - } - int size = 10; - - //when - var response = postService.findVotedPosts(user.getId(), null, size); - - //then - int 전체_15κ°œμ—μ„œ_맨_λ§ˆμ§€λ§‰_데이터_인덱슀 = posts.size() - size; - assertAll( - () -> assertThat(response.data()).hasSize(size), - () -> assertThat(response.hasNext()).isTrue(), - () -> assertThat(response.nextCursor()).isEqualTo(posts.get(전체_15κ°œμ—μ„œ_맨_λ§ˆμ§€λ§‰_데이터_인덱슀).getId()) - ); - } - - @Test - @DisplayName("ν”Όλ“œ 쑰회 - λ‚΄ κ²Œμ‹œκΈ€ 1개, 곡개 κ²Œμ‹œκΈ€ 15개, νˆ¬ν‘œ 10개, λŒ“κΈ€ 20개") - void findFeed() throws Exception { - //given - int size = 20; - User user1 = userRepository.save(createUser(1)); - User user2 = userRepository.save(createUser(2)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - - List publicPosts = createPosts(user2, Scope.PUBLIC); - List privatePosts = createPosts(user2, Scope.PRIVATE); - Post myPost = postRepository.save(createPost(user1.getId(), Scope.PUBLIC, imageFile1, imageFile2, 1)); - - createVotes(user1, publicPosts.getFirst()); - createComments(user1, publicPosts.getFirst()); - - List publicPostVotes = voteRepository.findByPostIdAndDeletedFalse(publicPosts.getFirst().getId()); - List publicPostComments = commentRepository.findByPostIdAndDeletedFalse(publicPosts.getFirst().getId()); - - //when - CursorBasePaginatedResponse response = postService.findFeed(user1.getId(), null, size); - - //then - assertAll( - () -> assertThat(response.data().size()).isEqualTo(16), - () -> assertThat(response.data().getLast().participantCount()).isEqualTo(publicPostVotes.size()), - () -> assertThat(response.data().getLast().commentCount()).isEqualTo(publicPostComments.size()), - () -> assertThat(response.data().getLast().isAuthor()).isFalse(), - () -> assertThat(response.data().getFirst().isAuthor()).isTrue() - ); - } - - private void createVotes(User user, Post post) { - for (int i = 0; i < 5; i++) { - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - voteRepository.save(createVote(user.getId(), post.getId(), imageFile1.getId())); - voteRepository.save(createVote(user.getId(), post.getId(), imageFile2.getId())); - } - } - - private void createComments(User user, Post post) { - for (int i = 0; i < 20; i++) { - commentRepository.save(createComment(user.getId(), post.getId())); - } - } -} diff --git a/src/test/java/com/swyp8team2/post/application/ShareUrlServiceTest.java b/src/test/java/com/swyp8team2/post/application/ShareUrlServiceTest.java deleted file mode 100644 index 64549dfc..00000000 --- a/src/test/java/com/swyp8team2/post/application/ShareUrlServiceTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.swyp8team2.post.application; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.*; - -class ShareUrlServiceTest { - - ShareUrlService shareUrlService; - - @BeforeEach - void setUp() throws Exception { - shareUrlService = new ShareUrlService("asdfd", "1541235432"); - } - - @Test - @DisplayName("μ•”ν˜Έν™” 및 λ³΅ν˜Έν™”") - void encryptAndDecrypt() { - // given - String plainText = "15411"; - - // when - String encryptedText = shareUrlService.encrypt(plainText); - System.out.println("encryptedText = " + encryptedText); - String decryptedText = shareUrlService.decrypt(encryptedText); - - // then - assertThat(decryptedText).isEqualTo(plainText); - } - - @Test - @DisplayName("μ•”ν˜Έν™” 및 λ³΅ν˜Έν™” - λ‹€λ₯Έ ν‚€") - void encryptAndDecrypt_differentKey() throws Exception { - // given - String plainText = "Hello, World!"; - ShareUrlService differentShareUrlService = new ShareUrlService("different", "234562"); - String encryptedText = differentShareUrlService.encrypt(plainText); - - // when then - assertThatThrownBy(() -> shareUrlService.decrypt(encryptedText)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); - } - - @Test - @DisplayName("λ³΅ν˜Έν™” - μ΄μƒν•œ 토큰") - void decrypt_invalidToken() { - // given - String invalid = "invalidToken"; - - // when then - assertThatThrownBy(() -> shareUrlService.decrypt(invalid)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); - } - - @Test - @DisplayName("λ³΅ν˜Έν™” - empty string") - void decrypt_emptyString() { - // given - String invalid = ""; - - // when then - assertThatThrownBy(() -> shareUrlService.decrypt(invalid)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); - } -} diff --git a/src/test/java/com/swyp8team2/post/domain/PostImageTest.java b/src/test/java/com/swyp8team2/post/domain/PostImageTest.java deleted file mode 100644 index 2d22b4bd..00000000 --- a/src/test/java/com/swyp8team2/post/domain/PostImageTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.swyp8team2.post.domain; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; - -class PostImageTest { - - @Test - @DisplayName("κ²Œμ‹œκΈ€ 이미지 생성") - void create() throws Exception { - //given - String name = "λ½€λ˜A"; - long imageFileId = 1L; - - //when - PostImage postImage = PostImage.create(name, imageFileId); - - //then - assertAll( - () -> assertThat(postImage.getName()).isEqualTo(name), - () -> assertThat(postImage.getImageFileId()).isEqualTo(imageFileId), - () -> assertThat(postImage.getVoteCount()).isEqualTo(0) - ); - } -} diff --git a/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java b/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java deleted file mode 100644 index 6e05019c..00000000 --- a/src/test/java/com/swyp8team2/post/domain/PostRepositoryTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.swyp8team2.post.domain; - -import com.swyp8team2.image.domain.ImageFile; -import com.swyp8team2.post.presentation.dto.FeedDto; -import com.swyp8team2.support.RepositoryTest; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; - -import java.util.ArrayList; -import java.util.List; - -import static com.swyp8team2.support.fixture.FixtureGenerator.*; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; - -class PostRepositoryTest extends RepositoryTest { - - @Autowired - PostRepository postRepository; - - @Autowired - UserRepository userRepository; - - @Test - @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - κ²Œμ‹œκΈ€μ΄ 15개일 경우 15λ²ˆμ¨°λΆ€ν„° 10개 μ‘°νšŒν•΄μ•Ό 함") - void select_post_findByUserId1() throws Exception { - //given - long userId = 1L; - List posts = createPosts(userId, Scope.PRIVATE); - int size = 10; - - //when - Slice res = postRepository.findByUserId(userId, null, PageRequest.ofSize(size)); - - //then - assertAll( - () -> assertThat(res.getContent().size()).isEqualTo(size), - () -> assertThat(res.getContent().get(0).getId()).isEqualTo(posts.get(posts.size() - 1).getId()), - () -> assertThat(res.getContent().get(1).getId()).isEqualTo(posts.get(posts.size() - 2).getId()), - () -> assertThat(res.getContent().get(2).getId()).isEqualTo(posts.get(posts.size() - 3).getId()), - () -> assertThat(res.hasNext()).isTrue() - ); - } - - @Test - @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회 - 15개 쀑에 μ»€μ„œκ°€ 5번째 κ²Œμ‹œκΈ€μ˜ idλ©΄ 4λ²ˆμ§ΈλΆ€ν„° 0λ²ˆμ§ΈκΉŒμ§€ μ‘°νšŒν•΄μ•Ό 함") - void select_post_findByUserId2() throws Exception { - //given - long userId = 1L; - List posts = createPosts(userId, Scope.PRIVATE); - int size = 10; - int cursorIndex = 5; - - //when - Slice res = postRepository.findByUserId(userId, posts.get(cursorIndex).getId(), PageRequest.ofSize(size)); - - //then - assertAll( - () -> assertThat(res.getContent().size()).isEqualTo(5), - () -> assertThat(res.getContent().get(0).getId()).isEqualTo(posts.get(cursorIndex - 1).getId()), - () -> assertThat(res.getContent().get(1).getId()).isEqualTo(posts.get(cursorIndex - 2).getId()), - () -> assertThat(res.getContent().get(2).getId()).isEqualTo(posts.get(cursorIndex - 3).getId()), - () -> assertThat(res.hasNext()).isFalse() - ); - } - - @Test - @DisplayName("id λ¦¬μŠ€νŠΈμ— ν¬ν•¨λ˜λŠ” κ²Œμ‹œκΈ€ 쑰회") - void select_post_findByIdIn() throws Exception { - //given - List posts = createPosts(1L, Scope.PRIVATE); - List postIds = List.of(posts.get(0).getId(), posts.get(1).getId(), posts.get(2).getId()); - - //when - Slice postSlice = postRepository.findByIdIn(postIds, null, PageRequest.ofSize(10)); - - //then - assertAll( - () -> assertThat(postSlice.getContent().size()).isEqualTo(postIds.size()), - () -> assertThat(postSlice.getContent().get(0).getId()).isEqualTo(postIds.get(2)), - () -> assertThat(postSlice.getContent().get(1).getId()).isEqualTo(postIds.get(1)), - () -> assertThat(postSlice.getContent().get(2).getId()).isEqualTo(postIds.get(0)), - () -> assertThat(postSlice.hasNext()).isFalse() - ); - } - - private List createPosts(long userId, Scope scope) { - List posts = new ArrayList<>(); - for (int i = 0; i < 30; i += 2) { - ImageFile imageFile1 = createImageFile(i); - ImageFile imageFile2 = createImageFile(i + 1); - posts.add(postRepository.save(createPost(userId, scope, imageFile1, imageFile2, i))); - } - return posts; - } - - @Test - @DisplayName("ν”Όλ“œ 쑰회") - void select_post_findByScopeAndDeletedFalse() { - //given - User user1 = userRepository.save(createUser(1)); - User user2 = userRepository.save(createUser(2)); - List myPosts = createPosts(user1.getId(), Scope.PRIVATE); - List privatePosts = createPosts(user2.getId(), Scope.PRIVATE); - List publicPosts = createPosts(user2.getId(), Scope.PUBLIC); - int size = 10; - - //when - Slice res = postRepository.findFeedByScopeWithUser(1L, null, PageRequest.ofSize(size)); - - //then - assertAll( - () -> assertThat(res.getContent().size()).isEqualTo(size), - () -> assertThat(res.hasNext()).isTrue() - ); - } -} diff --git a/src/test/java/com/swyp8team2/post/domain/PostTest.java b/src/test/java/com/swyp8team2/post/domain/PostTest.java deleted file mode 100644 index 4ebdb10d..00000000 --- a/src/test/java/com/swyp8team2/post/domain/PostTest.java +++ /dev/null @@ -1,205 +0,0 @@ -package com.swyp8team2.post.domain; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; - -class PostTest { - - @Test - @DisplayName("κ²Œμ‹œκΈ€ 생성") - void create() throws Exception { - //given - long userId = 1L; - String description = "description"; - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L), - PostImage.create("λ½€λ˜B", 2L) - ); - - //when - Post post = Post.create(userId, description, postImages, Scope.PRIVATE, VoteType.SINGLE); - - //then - List images = post.getImages(); - assertAll( - () -> assertThat(post.getUserId()).isEqualTo(userId), - () -> assertThat(post.getDescription()).isEqualTo(description), - () -> assertThat(post.getStatus()).isEqualTo(Status.PROGRESS), - () -> assertThat(images).hasSize(2), - () -> assertThat(images.get(0).getName()).isEqualTo("λ½€λ˜A"), - () -> assertThat(images.get(0).getImageFileId()).isEqualTo(1L), - () -> assertThat(images.get(0).getVoteCount()).isEqualTo(0), - () -> assertThat(images.get(1).getName()).isEqualTo("λ½€λ˜B"), - () -> assertThat(images.get(1).getImageFileId()).isEqualTo(2L), - () -> assertThat(images.get(1).getVoteCount()).isEqualTo(0) - ); - } - - @Test - @DisplayName("κ²Œμ‹œκΈ€ 생성 - 이미지가 2개 미만인 경우") - void create_invalidPostImageCount() throws Exception { - //given - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L) - ); - - //when then - assertThatThrownBy(() -> Post.create(1L, "description", postImages, Scope.PRIVATE, VoteType.SINGLE)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.INVALID_POST_IMAGE_COUNT.getMessage()); - } - - @Test - @DisplayName("κ²Œμ‹œκΈ€ 생성 - μ„€λͺ…이 100자 λ„˜μ–΄κ°€λŠ” 경우") - void create_descriptionCountExceeded() throws Exception { - //given - String description = "a".repeat(101); - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L), - PostImage.create("λ½€λ˜B", 2L) - ); - - //when then - assertThatThrownBy(() -> Post.create(1L, description, postImages, Scope.PRIVATE, VoteType.SINGLE)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.DESCRIPTION_LENGTH_EXCEEDED.getMessage()); - } - - @Test - @DisplayName("νˆ¬ν‘œ 마감") - void close() throws Exception { - //given - long userId = 1L; - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L), - PostImage.create("λ½€λ˜B", 2L) - ); - Post post = new Post(null, userId, "description", Status.PROGRESS, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); - - //when - post.close(userId); - - //then - assertThat(post.getStatus()).isEqualTo(Status.CLOSED); - } - - @Test - @DisplayName("νˆ¬ν‘œ 마감 - 이미 마감된 κ²Œμ‹œκΈ€μΈ 경우") - void close_alreadyClosed() throws Exception { - //given - long userId = 1L; - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L), - PostImage.create("λ½€λ˜B", 2L) - ); - Post post = new Post(null, userId, "description", Status.CLOSED, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); - - //when then - assertThatThrownBy(() -> post.close(userId)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); - } - - @Test - @DisplayName("νˆ¬ν‘œ 마감 - κ²Œμ‹œκΈ€ μž‘μ„±μžκ°€ μ•„λ‹Œ 경우") - void close_notPostAuthor() throws Exception { - //given - long userId = 1L; - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L), - PostImage.create("λ½€λ˜B", 2L) - ); - Post post = new Post(null, userId, "description", Status.PROGRESS, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); - - //when then - assertThatThrownBy(() -> post.close(2L)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); - } - - @Test - @DisplayName("κ²Œμ‹œκΈ€ 곡개 λ²”μœ„ μˆ˜μ •") - void toggleScope() throws Exception { - //given - long userId = 1L; - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L), - PostImage.create("λ½€λ˜B", 2L) - ); - Post post = new Post(null, userId, "description", Status.PROGRESS, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); - - //when then - post.toggleScope(userId); - assertThat(post.getScope()).isEqualTo(Scope.PUBLIC); - - //when then - post.toggleScope(userId); - assertThat(post.getScope()).isEqualTo(Scope.PRIVATE); - } - - @Test - @DisplayName("κ²Œμ‹œκΈ€ 곡개 λ²”μœ„ μˆ˜μ • - κ²Œμ‹œκΈ€ μž‘μ„±μžκ°€ μ•„λ‹Œ 경우") - void toggleScope_notPostAuthor() throws Exception { - //given - long userId = 1L; - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L), - PostImage.create("λ½€λ˜B", 2L) - ); - Post post = new Post(null, userId, "description", Status.PROGRESS, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); - - //when then - assertThatThrownBy(() -> post.toggleScope(2L)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.NOT_POST_AUTHOR.getMessage()); - } - - @Test - @DisplayName("κ²Œμ‹œκΈ€ 베슀트 ν”½ 쑰회") - void getBestPickedImage() throws Exception { - //given - long userId = 1L; - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L), - PostImage.create("λ½€λ˜B", 2L) - ); - Post post = new Post(null, userId, "description", Status.PROGRESS, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); - post.getImages().get(0).increaseVoteCount(); - post.getImages().get(0).increaseVoteCount(); - post.getImages().get(1).increaseVoteCount(); - - //when - PostImage bestPickedImage = post.getBestPickedImage(); - - //then - assertThat(bestPickedImage.getName()).isEqualTo("λ½€λ˜A"); - } - - @Test - @DisplayName("κ²Œμ‹œκΈ€ 베슀트 ν”½ 쑰회 - 동일 νˆ¬ν‘œμˆ˜μΈ 경우 첫 번째 이미지가 선택됨") - void getBestPickedImage_saveVoteCount() throws Exception { - //given - long userId = 1L; - List postImages = List.of( - PostImage.create("λ½€λ˜A", 1L), - PostImage.create("λ½€λ˜B", 2L) - ); - Post post = new Post(null, userId, "description", Status.PROGRESS, Scope.PRIVATE, postImages, "shareUrl", VoteType.SINGLE); - post.getImages().get(0).increaseVoteCount(); - post.getImages().get(1).increaseVoteCount(); - - //when - PostImage bestPickedImage = post.getBestPickedImage(); - - //then - assertThat(bestPickedImage.getName()).isEqualTo("λ½€λ˜A"); - } -} diff --git a/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java b/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java deleted file mode 100644 index bef91803..00000000 --- a/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java +++ /dev/null @@ -1,408 +0,0 @@ -package com.swyp8team2.post.presentation; - -import com.swyp8team2.common.dto.CursorBasePaginatedResponse; -import com.swyp8team2.post.domain.Scope; -import com.swyp8team2.post.domain.Status; -import com.swyp8team2.post.domain.VoteType; -import com.swyp8team2.post.presentation.dto.*; -import com.swyp8team2.support.RestDocsTest; -import com.swyp8team2.support.WithMockUserInfo; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.security.test.context.support.WithAnonymousUser; - -import java.time.LocalDateTime; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.*; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -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.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class PostControllerTest extends RestDocsTest { - - @Test - @WithMockUserInfo - @DisplayName("κ²Œμ‹œκΈ€ 생성") - void createPost() throws Exception { - //given - CreatePostRequest request = new CreatePostRequest( - "제λͺ©", - List.of(new PostImageRequestDto(1L), new PostImageRequestDto(2L)), - Scope.PRIVATE, - VoteType.SINGLE - ); - CreatePostResponse response = new CreatePostResponse(1L, "shareUrl"); - given(postService.create(any(), any())) - .willReturn(response); - - //when then - mockMvc.perform(post("/posts") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - requestFields( - fieldWithPath("description") - .type(JsonFieldType.STRING) - .description("μ„€λͺ…") - .attributes(constraints("0~100자 사이")), - fieldWithPath("images") - .type(JsonFieldType.ARRAY) - .description("νˆ¬ν‘œ 후보") - .attributes(constraints("μ΅œμ†Œ 2개")), - fieldWithPath("images[].imageFileId") - .type(JsonFieldType.NUMBER) - .description("νˆ¬ν‘œ 후보 이미지 ID"), - fieldWithPath("scope") - .type(JsonFieldType.STRING) - .description("νˆ¬ν‘œ κ³΅κ°œλ²”μœ„ (PRIVATE, PUBLIC)"), - fieldWithPath("voteType") - .type(JsonFieldType.STRING) - .description("νˆ¬ν‘œ 방식 (SINGLE, MULTIPLE)") - ), - responseFields( - fieldWithPath("postId") - .type(JsonFieldType.NUMBER) - .description("κ²Œμ‹œκΈ€ Id"), - fieldWithPath("shareUrl") - .type(JsonFieldType.STRING) - .description("κ²Œμ‹œκΈ€ 곡유 url") - ) - )); - } - - @Test - @WithAnonymousUser - @DisplayName("κ²Œμ‹œκΈ€ 곡유 url 상세 쑰회") - void findPost_shareUrl() throws Exception { - //given - PostResponse response = new PostResponse( - 1L, - new AuthorDto( - 1L, - "author", - "https://image.photopic.site/profile-image" - ), - "description", - List.of( - new PostImageResponse(1L, "λ½€λ˜A", "https://image.photopic.site/image/1", "https://image.photopic.site/image/resize/1", 1L), - new PostImageResponse(2L, "λ½€λ˜B", "https://image.photopic.site/image/2", "https://image.photopic.site/image/resize/2", null) - ), - "https://photopic.site/shareurl", - true, - Status.PROGRESS, - LocalDateTime.of(2025, 2, 13, 12, 0) - ); - given(postService.findByShareUrl(any(), any())) - .willReturn(response); - - //when then - mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/shareUrl/{shareUrl}", "JNOfBVfcG2z89afSiRrOyQ")) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andDo(restDocs.document( - pathParameters( - parameterWithName("shareUrl").description("곡유 url") - ), - responseFields( - fieldWithPath("id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ Id"), - fieldWithPath("author").type(JsonFieldType.OBJECT).description("κ²Œμ‹œκΈ€ μž‘μ„±μž 정보"), - fieldWithPath("author.id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μœ μ € Id"), - fieldWithPath("author.nickname").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž λ‹‰λ„€μž„"), - fieldWithPath("author.profileUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž ν”„λ‘œν•„ 이미지"), - fieldWithPath("description").type(JsonFieldType.STRING).description("μ„€λͺ…"), - fieldWithPath("images[]").type(JsonFieldType.ARRAY).description("νˆ¬ν‘œ 선택지 λͺ©λ‘"), - fieldWithPath("images[].id").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ 선택지 Id"), - fieldWithPath("images[].imageName").type(JsonFieldType.STRING).description("사진 이름"), - fieldWithPath("images[].imageUrl").type(JsonFieldType.STRING).description("사진 이미지"), - fieldWithPath("images[].thumbnailUrl").type(JsonFieldType.STRING).description("ν™•λŒ€ 사진 이미지"), - fieldWithPath("images[].voteId").type(JsonFieldType.NUMBER).optional().description("νˆ¬ν‘œ Id (νˆ¬ν‘œ μ•ˆ ν•œ 경우 null)"), - fieldWithPath("shareUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 곡유 URL"), - fieldWithPath("createdAt").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 생성 μ‹œκ°„"), - fieldWithPath("status").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 마감 μ—¬λΆ€ (PROGRESS, CLOSED)"), - fieldWithPath("isAuthor").type(JsonFieldType.BOOLEAN).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μ—¬λΆ€") - ) - )); - } - - @Test - @WithMockUserInfo - @DisplayName("κ²Œμ‹œκΈ€ μ‚­μ œ") - void deletePost() throws Exception { - //given - - //when then - mockMvc.perform(RestDocumentationRequestBuilders.delete("/posts/{postId}", 1) - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - pathParameters( - parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") - ) - )); - verify(postService, times(1)).delete(any(), any()); - } - - @Test - @WithMockUserInfo - @DisplayName("μœ μ €κ°€ μž‘μ„±ν•œ κ²Œμ‹œκΈ€ 쑰회") - void findMyPost() throws Exception { - //given - var response = new CursorBasePaginatedResponse<>( - 1L, - false, - List.of( - new SimplePostResponse( - 1L, - "https://image.photopic.site/1", - "https://photopic.site/shareurl", - LocalDateTime.of(2025, 2, 13, 12, 0) - ) - ) - ); - given(postService.findUserPosts(1L, null, 10)) - .willReturn(response); - - //when then - mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/users/{userId}", 1) - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andDo(restDocs.document( - pathParameters(parameterWithName("userId").description("μœ μ € Id")), - requestHeaders(authorizationHeader()), - queryParameters(cursorQueryParams()), - responseFields( - fieldWithPath("nextCursor") - .type(JsonFieldType.NUMBER) - .optional() - .description("λ‹€μŒ 쑰회 μ»€μ„œ κ°’"), - fieldWithPath("hasNext") - .type(JsonFieldType.BOOLEAN) - .description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€ (κΈ°λ³Έ κ°’ 10)"), - fieldWithPath("data[]") - .type(JsonFieldType.ARRAY) - .description("κ²Œμ‹œκΈ€ 데이터"), - fieldWithPath("data[].id") - .type(JsonFieldType.NUMBER) - .description("κ²Œμ‹œκΈ€ Id"), - fieldWithPath("data[].bestPickedImageUrl") - .type(JsonFieldType.STRING) - .description("κ°€μž₯ λ§Žμ€ λ“ν‘œλ₯Ό 받은 이미지 URL"), - fieldWithPath("data[].shareUrl") - .type(JsonFieldType.STRING) - .description("κ²Œμ‹œκΈ€ 곡유 URL"), - fieldWithPath("data[].createdAt") - .type(JsonFieldType.STRING) - .description("κ²Œμ‹œκΈ€ 생성 μ‹œκ°„") - ) - )); - } - - @Test - @WithMockUserInfo - @DisplayName("μœ μ €κ°€ μ°Έμ—¬ν•œ κ²Œμ‹œκΈ€ 쑰회") - void findVotedPost() throws Exception { - //given - var response = new CursorBasePaginatedResponse<>( - 1L, - false, - List.of( - new SimplePostResponse( - 1L, - "https://image.photopic.site/1", - "https://photopic.site/shareurl", - LocalDateTime.of(2025, 2, 13, 12, 0) - ) - ) - ); - given(postService.findVotedPosts(1L, null, 10)) - .willReturn(response); - - //when then - mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/users/{userId}/voted", 1) - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andDo(restDocs.document( - pathParameters(parameterWithName("userId").description("μœ μ € Id")), - requestHeaders(authorizationHeader()), - queryParameters(cursorQueryParams()), - responseFields( - fieldWithPath("nextCursor") - .type(JsonFieldType.NUMBER) - .optional() - .description("λ‹€μŒ 쑰회 μ»€μ„œ κ°’"), - fieldWithPath("hasNext") - .type(JsonFieldType.BOOLEAN) - .description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€ (κΈ°λ³Έ κ°’ 10)"), - fieldWithPath("data[]") - .type(JsonFieldType.ARRAY) - .description("κ²Œμ‹œκΈ€ 데이터"), - fieldWithPath("data[].id") - .type(JsonFieldType.NUMBER) - .description("κ²Œμ‹œκΈ€ Id"), - fieldWithPath("data[].bestPickedImageUrl") - .type(JsonFieldType.STRING) - .description("κ°€μž₯ λ§Žμ€ λ“ν‘œλ₯Ό 받은 이미지 URL"), - fieldWithPath("data[].shareUrl") - .type(JsonFieldType.STRING) - .description("κ²Œμ‹œκΈ€ 곡유 URL"), - fieldWithPath("data[].createdAt") - .type(JsonFieldType.STRING) - .description("κ²Œμ‹œκΈ€ 생성 μ‹œκ°„") - ) - )); - } - - @Test - @WithMockUserInfo - @DisplayName("κ²Œμ‹œκΈ€ 곡개 λ²”μœ„ λ³€κ²½") - void toggleStatusPost() throws Exception { - //given - Long postId = 1L; - doNothing().when(postService).toggleScope(any(), eq(postId)); - - //when then - mockMvc.perform(post("/posts/{postId}/scope", 1) - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - pathParameters( - parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") - ) - )); - - verify(postService, times(1)).toggleScope(any(), eq(postId)); - } - - @Test - @WithMockUserInfo - @DisplayName("κ²Œμ‹œκΈ€ μˆ˜μ •") - void updatePost() throws Exception { - //given - UpdatePostRequest request = new UpdatePostRequest("μ„€λͺ…"); - - //when then - mockMvc.perform(post("/posts/{postId}/update", 1) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - pathParameters( - parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") - ), - requestFields( - fieldWithPath("description") - .type(JsonFieldType.STRING) - .description("μ„€λͺ…") - .attributes(constraints("0~100자 사이")) - ) - )); - } - - @Test - @WithMockUserInfo - @DisplayName("κ²Œμ‹œκΈ€ 마감") - void closePost() throws Exception { - //given - - //when then - mockMvc.perform(RestDocumentationRequestBuilders.post("/posts/{postId}/close", 1) - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - pathParameters( - parameterWithName("postId").description("κ²Œμ‹œκΈ€ Id") - ) - )); - verify(postService, times(1)).close(any(), any()); - } - - @Test - @WithMockUserInfo - @DisplayName("ν”Όλ“œ 쑰회") - void findFeed() throws Exception { - //given - var response = new CursorBasePaginatedResponse<> ( - 1L, - false, - List.of( - new FeedResponse( - 1L, - new AuthorDto( - 1L, - "author", - "https://image.photopic.site/profile-image" - ), - List.of( - new PostImageResponse(1L, "λ½€λ˜A", "https://image.photopic.site/image/1", "https://image.photopic.site/image/resize/1", 1L), - new PostImageResponse(2L, "λ½€λ˜B", "https://image.photopic.site/image/2", "https://image.photopic.site/image/resize/2", null) - ), - Status.PROGRESS, - "description", - "anioefw78f329jcs9", - true, - 1L, - 2L - ) - ) - ); - given(postService.findFeed(1L, null, 10)).willReturn(response); - - //when then - mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/feed") - .header(HttpHeaders.AUTHORIZATION, "Bearer token")) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - queryParameters(cursorQueryParams()), - responseFields( - fieldWithPath("nextCursor").type(JsonFieldType.NUMBER).optional().description("λ‹€μŒ 쑰회 μ»€μ„œ κ°’"), - fieldWithPath("hasNext").type(JsonFieldType.BOOLEAN).description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€ (κΈ°λ³Έ κ°’ 10)"), - fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("κ²Œμ‹œκΈ€ 데이터"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ Id"), - fieldWithPath("data[].author").type(JsonFieldType.OBJECT).description("κ²Œμ‹œκΈ€ μž‘μ„±μž 정보"), - fieldWithPath("data[].author.id").type(JsonFieldType.NUMBER).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μœ μ € ID"), - fieldWithPath("data[].author.nickname").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž λ‹‰λ„€μž„"), - fieldWithPath("data[].author.profileUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ μž‘μ„±μž ν”„λ‘œν•„ 이미지"), - fieldWithPath("data[].images[]").type(JsonFieldType.ARRAY).description("νˆ¬ν‘œ 선택지 λͺ©λ‘"), - fieldWithPath("data[].images[].id").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ 선택지 Id"), - fieldWithPath("data[].images[].imageName").type(JsonFieldType.STRING).description("사진 이름"), - fieldWithPath("data[].images[].imageUrl").type(JsonFieldType.STRING).description("사진 이미지"), - fieldWithPath("data[].images[].thumbnailUrl").type(JsonFieldType.STRING).description("λ‚˜μ€‘μ— μ—†μ–΄μ§ˆ μ˜ˆμ •"), - fieldWithPath("data[].images[].voteId").type(JsonFieldType.NUMBER).optional().description("νˆ¬ν‘œ Id (νˆ¬ν‘œ μ•ˆ ν•œ 경우 null)"), - fieldWithPath("data[].status").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 마감 μ—¬λΆ€ (PROGRESS, CLOSED)"), - fieldWithPath("data[].description").type(JsonFieldType.STRING).description("μ„€λͺ…"), - fieldWithPath("data[].shareUrl").type(JsonFieldType.STRING).description("κ²Œμ‹œκΈ€ 곡유 URL"), - fieldWithPath("data[].isAuthor").type(JsonFieldType.BOOLEAN).description("κ²Œμ‹œκΈ€ μž‘μ„±μž μ—¬λΆ€"), - fieldWithPath("data[].participantCount").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ μ°Έμ—¬μž 수"), - fieldWithPath("data[].commentCount").type(JsonFieldType.NUMBER).description("νˆ¬ν‘œ λŒ“κΈ€ 수") - ) - )); - } -} diff --git a/src/test/java/com/swyp8team2/support/IntegrationTest.java b/src/test/java/com/swyp8team2/support/IntegrationTest.java deleted file mode 100644 index 357ab8e4..00000000 --- a/src/test/java/com/swyp8team2/support/IntegrationTest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.swyp8team2.support; - -import jakarta.transaction.Transactional; -import org.springframework.boot.test.context.SpringBootTest; - -@Transactional -@SpringBootTest -public abstract class IntegrationTest { -} diff --git a/src/test/java/com/swyp8team2/support/RepositoryTest.java b/src/test/java/com/swyp8team2/support/RepositoryTest.java deleted file mode 100644 index 08df0a0a..00000000 --- a/src/test/java/com/swyp8team2/support/RepositoryTest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.swyp8team2.support; - -import com.swyp8team2.common.config.JpaConfig; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; - -@Import(JpaConfig.class) -@DataJpaTest -public abstract class RepositoryTest { -} diff --git a/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java b/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java deleted file mode 100644 index 2a3ebe58..00000000 --- a/src/test/java/com/swyp8team2/support/fixture/FixtureGenerator.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.swyp8team2.support.fixture; - -import com.swyp8team2.comment.domain.Comment; -import com.swyp8team2.image.domain.ImageFile; -import com.swyp8team2.image.presentation.dto.ImageFileDto; -import com.swyp8team2.post.domain.Post; -import com.swyp8team2.post.domain.PostImage; -import com.swyp8team2.post.domain.Scope; -import com.swyp8team2.post.domain.VoteType; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.vote.domain.Vote; - -import java.util.List; -import java.util.stream.Collectors; - -public abstract class FixtureGenerator { - - public static Post createPost(Long userId, Scope scope, ImageFile imageFile1, ImageFile imageFile2, int key) { - return Post.create( - userId, - "description" + key, - List.of( - PostImage.create("λ½€λ˜A", imageFile1.getId()), - PostImage.create("λ½€λ˜B", imageFile2.getId()) - ), - scope, - VoteType.SINGLE - ); - } - - public static Post createPost(Long userId, Scope scope, List imageFiles, int key) { - return Post.create( - userId, - "description" + key, - imageFiles.stream() - .map(imageFile -> PostImage.create("λ½€λ˜"+key, imageFile.getId())) - .toList(), - scope, - VoteType.SINGLE - ); - } - - public static Post createMultiplePost(Long userId, Scope scope, ImageFile imageFile1, ImageFile imageFile2, int key) { - return Post.create( - userId, - "description" + key, - List.of( - PostImage.create("λ½€λ˜A", imageFile1.getId()), - PostImage.create("λ½€λ˜B", imageFile2.getId()) - ), - scope, - VoteType.MULTIPLE - ); - } - - public static User createUser(int key) { - return User.create("nickname" + key, "profileUrl" + key); - } - - public static ImageFile createImageFile(int key) { - return ImageFile.create( - new ImageFileDto( - "originalFileName" + key, - "imageUrl" + key, - "thumbnailUrl" + key - ) - ); - } - - public static Vote createVote(Long userId, Long postId, Long imageId) { - return Vote.of(userId, postId, imageId); - } - - public static Comment createComment(Long userId, Long postId) { - return new Comment(userId, postId, "λ‚΄μš©"); - } -} diff --git a/src/test/java/com/swyp8team2/user/application/NicknameGeneratorTest.java b/src/test/java/com/swyp8team2/user/application/NicknameGeneratorTest.java deleted file mode 100644 index fb7d5671..00000000 --- a/src/test/java/com/swyp8team2/user/application/NicknameGeneratorTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.swyp8team2.user.application; - -import com.swyp8team2.user.domain.NicknameAdjective; -import com.swyp8team2.user.domain.NicknameAdjectiveRepository; -import com.swyp8team2.user.domain.Role; -import com.swyp8team2.user.domain.User; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; - -@ExtendWith(MockitoExtension.class) -class NicknameGeneratorTest { - - @InjectMocks - NicknameGenerator nicknameGenerator; - - @Mock - NicknameAdjectiveRepository nicknameAdjectiveRepository; - - @Test - @DisplayName("λ‹‰λ„€μž„ 생성 ν…ŒμŠ€νŠΈ") - void generate() throws Exception { - //given - Role role = Role.USER; - given(nicknameAdjectiveRepository.findRandomNicknameAdjective()) - .willReturn(Optional.of(new NicknameAdjective("ν˜ΈκΈ°μ‹¬ λ§Žμ€"))); - - //when - String nickname = nicknameGenerator.generate(role); - - //then - Assertions.assertThat(nickname).isEqualTo("ν˜ΈκΈ°μ‹¬ λ§Žμ€ λ½€λ˜"); - } - - @Test - @DisplayName("λ‹‰λ„€μž„ 생성 ν…ŒμŠ€νŠΈ - 게슀트") - void generate_guest() throws Exception { - //given - Role role = Role.GUEST; - given(nicknameAdjectiveRepository.findRandomNicknameAdjective()) - .willReturn(Optional.of(new NicknameAdjective("ν˜ΈκΈ°μ‹¬ λ§Žμ€"))); - - //when - String nickname = nicknameGenerator.generate(role); - - //then - Assertions.assertThat(nickname).isEqualTo("ν˜ΈκΈ°μ‹¬ λ§Žμ€ λ‚«λ˜"); - } -} diff --git a/src/test/java/com/swyp8team2/user/application/UserServiceTest.java b/src/test/java/com/swyp8team2/user/application/UserServiceTest.java deleted file mode 100644 index ce483924..00000000 --- a/src/test/java/com/swyp8team2/user/application/UserServiceTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.swyp8team2.user.application; - -import com.swyp8team2.support.IntegrationTest; -import com.swyp8team2.user.domain.NicknameAdjective; -import com.swyp8team2.user.domain.NicknameAdjectiveRepository; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -class UserServiceTest extends IntegrationTest { - - @Autowired - UserRepository userRepository; - - @Autowired - NicknameAdjectiveRepository nicknameAdjectiveRepository; - - @Autowired - UserService userService; - - @Test - void createUser() { - // given - User user = User.create(null, "https://image.com/1"); - - for (int i = 0; i < 250; i++) { - nicknameAdjectiveRepository.save(new NicknameAdjective("ν˜ΈκΈ°μ‹¬ λ§Žμ€ λ½€λ˜")); - nicknameAdjectiveRepository.save(new NicknameAdjective("λ°°λ € κΉŠμ€ λ½€λ˜")); - } - - // when - Long userId = userService.createUser(user.getNickname(), user.getProfileUrl()); - Optional returnUser = userRepository.findById(userId); - - // when then - assertAll( - () -> assertThat(returnUser.get().getNickname()).isNotNull(), - () -> assertThat(returnUser.get().getNickname()).contains("λ½€λ˜") - ); - - } -} \ No newline at end of file diff --git a/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java b/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java deleted file mode 100644 index 02906b05..00000000 --- a/src/test/java/com/swyp8team2/user/presentation/UserControllerTest.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.swyp8team2.user.presentation; - -import com.swyp8team2.support.RestDocsTest; -import com.swyp8team2.support.WithMockUserInfo; -import com.swyp8team2.user.domain.Role; -import com.swyp8team2.user.presentation.dto.UserInfoResponse; -import com.swyp8team2.user.presentation.dto.UserMyInfoResponse; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; -import org.springframework.security.test.context.support.WithMockUser; - -import static org.mockito.BDDMockito.given; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; -import static org.springframework.restdocs.payload.JsonFieldType.STRING; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class UserControllerTest extends RestDocsTest { - - @Test - @WithMockUserInfo - @DisplayName("μœ μ € 정보 쑰회") - void findUserInfo() throws Exception { - //given - UserInfoResponse response = new UserInfoResponse(1L, "nickname", "https://image.com/profile-image"); - given(userService.findById(1L)) - .willReturn(response); - - //when then - mockMvc.perform(RestDocumentationRequestBuilders.get("/users/{userId}", "1")) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andDo(restDocs.document( - pathParameters( - parameterWithName("userId").description("μœ μ € 아이디") - ), - responseFields( - fieldWithPath("id").description("μœ μ € 아이디").type(NUMBER), - fieldWithPath("nickname").description("λ‹‰λ„€μž„").type(STRING), - fieldWithPath("profileUrl").description("ν”„λ‘œν•„ 이미지 URL").type(STRING) - ) - )); - } - - @Test - @WithMockUserInfo - @DisplayName("본인 정보 쑰회") - void findMe() throws Exception { - //given - UserMyInfoResponse response = new UserMyInfoResponse(1L, "nickname", "https://image.com/profile-image", Role.USER); - given(userService.findByMe(1L)) - .willReturn(response); - - //when then - mockMvc.perform(RestDocumentationRequestBuilders.get("/users/me") - .header(HttpHeaders.AUTHORIZATION, "Bearer access-token")) - .andExpect(status().isOk()) - .andExpect(content().json(objectMapper.writeValueAsString(response))) - .andDo(restDocs.document( - requestHeaders(authorizationHeader()), - responseFields( - fieldWithPath("id").description("μœ μ € 아이디").type(NUMBER), - fieldWithPath("nickname").description("λ‹‰λ„€μž„").type(STRING), - fieldWithPath("profileImageUrl").description("ν”„λ‘œν•„ 이미지 URL").type(STRING), - fieldWithPath("role").description("μœ μ € κΆŒν•œ").type(STRING) - ) - )); - } -} diff --git a/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java b/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java deleted file mode 100644 index c05f5dca..00000000 --- a/src/test/java/com/swyp8team2/vote/application/VoteServiceTest.java +++ /dev/null @@ -1,265 +0,0 @@ -package com.swyp8team2.vote.application; - -import com.swyp8team2.common.exception.BadRequestException; -import com.swyp8team2.common.exception.ErrorCode; -import com.swyp8team2.image.domain.ImageFile; -import com.swyp8team2.image.domain.ImageFileRepository; -import com.swyp8team2.post.domain.*; -import com.swyp8team2.support.IntegrationTest; -import com.swyp8team2.user.domain.User; -import com.swyp8team2.user.domain.UserRepository; -import com.swyp8team2.vote.domain.Vote; -import com.swyp8team2.vote.domain.VoteRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -import java.util.List; - -import static com.swyp8team2.support.fixture.FixtureGenerator.createImageFile; -import static com.swyp8team2.support.fixture.FixtureGenerator.createMultiplePost; -import static com.swyp8team2.support.fixture.FixtureGenerator.createPost; -import static com.swyp8team2.support.fixture.FixtureGenerator.createUser; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; - -class VoteServiceTest extends IntegrationTest { - - @Autowired - VoteService voteService; - - @Autowired - UserRepository userRepository; - - @Autowired - VoteRepository voteRepository; - - @Autowired - PostRepository postRepository; - - @Autowired - ImageFileRepository imageFileRepository; - - @Test - @DisplayName("단일 νˆ¬ν‘œν•˜κΈ°") - void singleVote() { - // given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - - // when - Long voteId = voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); - - // then - Vote vote = voteRepository.findById(voteId).get(); - Post findPost = postRepository.findById(post.getId()).get(); - assertAll( - () -> assertThat(vote.getUserId()).isEqualTo(user.getId()), - () -> assertThat(vote.getPostId()).isEqualTo(post.getId()), - () -> assertThat(vote.getPostImageId()).isEqualTo(post.getImages().get(0).getId()), - () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(1) - ); - } - - @Test - @DisplayName("단일 νˆ¬ν‘œν•˜κΈ° - λ‹€λ₯Έ μ΄λ―Έμ§€λ‘œ νˆ¬ν‘œ λ³€κ²½ν•œ 경우") - void singleVote_change() { - // given - User user = userRepository.save(createUser(2)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); - - // when - Long voteId = voteService.vote(user.getId(), post.getId(), post.getImages().get(1).getId()); - - // then - Vote vote = voteRepository.findById(voteId).get(); - Post findPost = postRepository.findById(post.getId()).get(); - assertAll( - () -> assertThat(vote.getUserId()).isEqualTo(user.getId()), - () -> assertThat(vote.getPostId()).isEqualTo(post.getId()), - () -> assertThat(vote.getPostImageId()).isEqualTo(post.getImages().get(1).getId()), - () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(0), - () -> assertThat(findPost.getImages().get(1).getVoteCount()).isEqualTo(1) - ); - } - - @Test - @DisplayName("볡수 νˆ¬ν‘œν•˜κΈ°") - void multipleVote() { - // given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createMultiplePost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - - // when - Long voteId1 = voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); - Long voteId2 = voteService.vote(user.getId(), post.getId(), post.getImages().get(1).getId()); - - // then - Vote vote1 = voteRepository.findById(voteId1).get(); - Vote vote2 = voteRepository.findById(voteId2).get(); - Post findPost = postRepository.findById(post.getId()).get(); - assertAll( - () -> assertThat(vote1.getUserId()).isEqualTo(user.getId()), - () -> assertThat(vote1.getPostId()).isEqualTo(post.getId()), - () -> assertThat(vote1.getPostImageId()).isEqualTo(post.getImages().get(0).getId()), - - () -> assertThat(vote2.getUserId()).isEqualTo(user.getId()), - () -> assertThat(vote2.getPostId()).isEqualTo(post.getId()), - () -> assertThat(vote2.getPostImageId()).isEqualTo(post.getImages().get(1).getId()), - - () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(1), - () -> assertThat(findPost.getImages().get(1).getVoteCount()).isEqualTo(1) - ); - } - - @Test - @DisplayName("νˆ¬ν‘œν•˜κΈ° - νˆ¬ν‘œ 마감된 경우") - void vote_alreadyClosed() { - // given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(new Post( - null, - user.getId(), - "description", - Status.CLOSED, - Scope.PRIVATE, - List.of( - PostImage.create("λ½€λ˜A", imageFile1.getId()), - PostImage.create("λ½€λ˜B", imageFile2.getId()) - ), - "shareUrl", - VoteType.SINGLE - )); - - // when - assertThatThrownBy(() -> voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId())) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.POST_ALREADY_CLOSED.getMessage()); - } - - @Test - @DisplayName("νˆ¬ν‘œ μ·¨μ†Œ") - void cancelVote() { - // given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - Long voteId = voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); - - // when - voteService.cancelVote(user.getId(), voteId); - - // then - boolean res = voteRepository.findById(voteId).isEmpty(); - Post findPost = postRepository.findById(post.getId()).get(); - assertAll( - () -> assertThat(res).isEqualTo(true), - () -> assertThat(findPost.getImages().get(0).getVoteCount()).isEqualTo(0) - ); - } - - @Test - @DisplayName("νˆ¬ν‘œ μ·¨μ†Œ - νˆ¬ν‘œμžκ°€ μ•„λ‹Œ 경우") - void cancelVote_notVoter() { - // given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - Long voteId = voteService.vote(user.getId(), post.getId(), post.getImages().get(0).getId()); - - // when then - assertThatThrownBy(() -> voteService.cancelVote(2L, voteId)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.NOT_VOTER.getMessage()); - } - - @Test - @DisplayName("νˆ¬ν‘œ ν˜„ν™© 쑰회") - void findVoteStatus() throws Exception { - //given - User user = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - ImageFile imageFile3 = imageFileRepository.save(createImageFile(3)); - Post post = postRepository.save(createPost(user.getId(), Scope.PRIVATE, List.of(imageFile1, imageFile2, imageFile3), 1)); - voteService.vote(user.getId(), post.getId(), post.getImages().get(1).getId()); - - //when - var response = voteService.findVoteStatus(user.getId(), post.getId()); - - //then - assertAll( - () -> assertThat(response).hasSize(3), - () -> assertThat(response.get(0).id()).isEqualTo(post.getImages().get(1).getId()), - () -> assertThat(response.get(0).imageName()).isEqualTo(post.getImages().get(1).getName()), - () -> assertThat(response.get(0).voteCount()).isEqualTo(1), - () -> assertThat(response.get(0).voteRatio()).isEqualTo("100.0"), - - () -> assertThat(response.get(1).id()).isEqualTo(post.getImages().get(0).getId()), - () -> assertThat(response.get(1).imageName()).isEqualTo(post.getImages().get(0).getName()), - () -> assertThat(response.get(1).voteCount()).isEqualTo(0), - () -> assertThat(response.get(1).voteRatio()).isEqualTo("0.0"), - - () -> assertThat(response.get(2).id()).isEqualTo(post.getImages().get(2).getId()), - () -> assertThat(response.get(2).imageName()).isEqualTo(post.getImages().get(2).getName()), - () -> assertThat(response.get(2).voteCount()).isEqualTo(0), - () -> assertThat(response.get(2).voteRatio()).isEqualTo("0.0") - ); - } - - @Test - @DisplayName("νˆ¬ν‘œ ν˜„ν™© 쑰회 - νˆ¬ν‘œν•œ μ‚¬λžŒμΈ 경우") - void findVoteStatus_voteUser() throws Exception { - //given - User author = userRepository.save(createUser(1)); - User voter = userRepository.save(createUser(2)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(author.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - voteService.vote(voter.getId(), post.getId(), post.getImages().get(0).getId()); - - //when - var response = voteService.findVoteStatus(voter.getId(), post.getId()); - - //then - assertAll( - () -> assertThat(response).hasSize(2), - () -> assertThat(response.get(0).id()).isEqualTo(post.getImages().get(0).getId()), - () -> assertThat(response.get(0).imageName()).isEqualTo(post.getImages().get(0).getName()), - () -> assertThat(response.get(0).voteCount()).isEqualTo(1), - () -> assertThat(response.get(0).voteRatio()).isEqualTo("100.0"), - () -> assertThat(response.get(1).id()).isEqualTo(post.getImages().get(1).getId()), - () -> assertThat(response.get(1).imageName()).isEqualTo(post.getImages().get(1).getName()), - () -> assertThat(response.get(1).voteCount()).isEqualTo(0), - () -> assertThat(response.get(1).voteRatio()).isEqualTo("0.0") - ); - } - - @Test - @DisplayName("νˆ¬ν‘œ ν˜„ν™© 쑰회 - μž‘μ„±μž μ•„λ‹ˆκ³  νˆ¬ν‘œ μ•ˆ ν•œ μ‚¬λžŒμΈ 경우") - void findVoteStatus_notAuthorAndVoter() throws Exception { - //given - User author = userRepository.save(createUser(1)); - ImageFile imageFile1 = imageFileRepository.save(createImageFile(1)); - ImageFile imageFile2 = imageFileRepository.save(createImageFile(2)); - Post post = postRepository.save(createPost(author.getId(), Scope.PRIVATE, imageFile1, imageFile2, 1)); - - //when - assertThatThrownBy(() -> voteService.findVoteStatus(2L, post.getId())) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorCode.ACCESS_DENIED_VOTE_STATUS.getMessage()); - } - -}