diff --git a/.github/workflows/cd-back-dev.yml b/.github/workflows/cd-back-dev.yml index c6ec9db95..e23c71e9a 100644 --- a/.github/workflows/cd-back-dev.yml +++ b/.github/workflows/cd-back-dev.yml @@ -4,10 +4,14 @@ on: push: branches: - dev - paths: 'backend/**' - + paths: + - 'backend/**' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + defaults: run: working-directory: backend @@ -16,28 +20,38 @@ jobs: build: runs-on: ubuntu-latest steps: - - name: 리포지토리 체크아웃 + - name: repository checkout uses: actions/checkout@v3 with: submodules: recursive token: ${{ secrets.SUBMODULE_TOKEN }} - - name: 자바 설치 + - name: install java 17 uses: actions/setup-java@v3 with: java-version: 17 distribution: 'zulu' - - name: gradlew 권한 부여 + - name: assign grant gradlew run: chmod +x gradlew - - name: Gradle Test - run: ./gradlew test + - name: bootJar with gradle + run: ./gradlew bootJar + + - name: Docker Login + uses: docker/login-action@v3.1.0 + with: + username: ${{ vars.DOCKER_HUB_DEV_USERNAME }} + password: ${{ secrets.DOCKER_HUB_DEV_LOGIN_TOKEN }} + + - name: Build And Push docker image + run: docker build --platform linux/arm64/v8 --push --tag ${{ vars.DOCKER_DEV_TAG }} . - - name: trigger to jenkins dev cd - uses: appleboy/jenkins-action@master + - name: run application use ssh + uses: appleboy/ssh-action@master with: - url: ${{ secrets.JENKINS_URL }} - user: "festago" - token: ${{ secrets.JENKINS_API_TOKEN}} - job: "festago-dev-cd" + host: ${{ vars.FESTAGO_DEV_IP }} + username: ${{ vars.FESTAGO_DEV_USERNAME }} + key: ${{secrets.FESTAGO_DEV_SSH_KEY}} + script_stop: true + script: ${{ vars.FESTAGO_DEV_DEPLOY_COMMAND }} diff --git a/.github/workflows/cd-back-prod.yml b/.github/workflows/cd-back-prod.yml new file mode 100644 index 000000000..5bf67a8af --- /dev/null +++ b/.github/workflows/cd-back-prod.yml @@ -0,0 +1,48 @@ +name: CD-Back-Prod + +on: + workflow_dispatch: + +defaults: + run: + working-directory: backend + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: repository checkout + uses: actions/checkout@v3 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} + + - name: install java 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: 'zulu' + + - name: assign grant gradlew + run: chmod +x gradlew + + - name: bootJar with gradle + run: ./gradlew bootJar + + - name: Docker Login + uses: docker/login-action@v3.1.0 + with: + username: ${{ vars.DOCKER_HUB_PROD_USERNAME }} + password: ${{ secrets.DOCKER_HUB_PROD_LOGIN_TOKEN }} + + - name: Build And Push docker image + run: docker build --platform linux/arm64/v8 --push --tag ${{ vars.DOCKER_PROD_TAG }} . + + - name: run application use ssh + uses: appleboy/ssh-action@master + with: + host: ${{ vars.FESTAGO_PROD_IP }} + username: ${{ vars.FESTAGO_PROD_USERNAME }} + key: ${{secrets.FESTAGO_PROD_SSH_KEY}} + script_stop: true + script: ${{ vars.FESTAGO_PROD_DEPLOY_COMMAND }} diff --git a/.github/workflows/ci-back.yml b/.github/workflows/ci-back.yml index 849fea334..b15191978 100644 --- a/.github/workflows/ci-back.yml +++ b/.github/workflows/ci-back.yml @@ -1,11 +1,13 @@ -name: CI-Back +name: CI Back on: pull_request: branches: - dev - main - paths: 'backend/**' + - feat/** + paths: + - backend/** defaults: run: @@ -15,33 +17,42 @@ jobs: build: runs-on: ubuntu-latest steps: - - name: 리포지토리 체크아웃 + - name: Repository checkout uses: actions/checkout@v3 with: submodules: recursive token: ${{ secrets.SUBMODULE_TOKEN }} - - name: 자바 설치 + - name: Setup java 17 uses: actions/setup-java@v3 with: java-version: 17 distribution: 'zulu' - - name: gradlew 권한 부여 + - name: Cache gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Assign grant gradlew run: chmod +x gradlew - - name: Gradle build - run: ./gradlew build + - name: Test with gradle + run: ./gradlew --info test - - name: 테스트 결과 PR에 커멘트 등록 - uses: EnricoMi/publish-unit-test-result-action@v1 + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: files: '**/build/test-results/test/TEST-*.xml' - - name: 테스트 실패 Check 코멘트 등록 - uses: mikepenz/action-junit-report@v3 + - name: Publish test report + uses: mikepenz/action-junit-report@v4 if: always() with: report_paths: '**/build/test-results/test/TEST-*.xml' - token: ${{ github.token }} diff --git a/.github/workflows/closed-issue-notification.yml b/.github/workflows/closed-issue-notification.yml deleted file mode 100644 index 6031248af..000000000 --- a/.github/workflows/closed-issue-notification.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Closed Issue Notification -on: - issues: - types: - - closed - -jobs: - create-issue: - name: Send closed issue notification to slack - runs-on: ubuntu-latest - steps: - - name: Send Issue - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - text: "*이슈가 닫혔습니다!*", - attachments: [{ - fallback: 'fallback', - color: '#7539DE', - title: 'Title', - text: '<${{ github.event.issue.html_url }}|${{ github.event.issue.title }}>', - fields: [{ - title: 'Issue number', - value: '#${{ github.event.issue.number }}', - short: true - }, - { - title: 'Author', - value: '${{ github.event.issue.user.login }}', - short: true - }], - actions: [{ - }] - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_ISSUE_WEBHOOK_URL }} - if: always() diff --git a/.github/workflows/closed-pr-notification.yml b/.github/workflows/closed-pr-notification.yml deleted file mode 100644 index d92c2d37e..000000000 --- a/.github/workflows/closed-pr-notification.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: Closed PR Notification -on: - pull_request: - branches: - - dev - - main - types: - - closed - -jobs: - create-issue: - name: PR closed notification to slack - runs-on: ubuntu-latest - steps: - - name: Send closed PR notification - if: github.event.pull_request.merged != true - uses: slackapi/slack-github-action@v1.24.0 - with: - payload: | - { - "text": "*PR이 닫혔습니다!*", - "attachments": [ - { - "color": "#CF2027", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Title*\n<${{ github.event.pull_request.html_url }}|${{ github.event.pull_request.title }}>" - } - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Base branch*\n${{ github.base_ref }}" - }, - { - "type": "mrkdwn", - "text": "*Compare branch*\n${{ github.head_ref }}" - }, - { - "type": "mrkdwn", - "text": "*PR number*\n#${{ github.event.pull_request.number }}" - }, - { - "type": "mrkdwn", - "text": "*Author*\n${{ github.event.pull_request.user.login }}" - } - ] - } - ] - } - ] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_PR_WEBHOOK_URL }} - SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK - - name: Send merged PR notification - if: github.event.pull_request.merged == true - uses: slackapi/slack-github-action@v1.24.0 - with: - payload: | - { - "text": "*PR이 머지됐습니다!*", - "attachments": [ - { - "color": "#7539DE", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Title*\n<${{ github.event.pull_request.html_url }}|${{ github.event.pull_request.title }}>" - } - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Base branch*\n${{ github.base_ref }}" - }, - { - "type": "mrkdwn", - "text": "*Compare branch*\n${{ github.head_ref }}" - }, - { - "type": "mrkdwn", - "text": "*PR number*\n#${{ github.event.pull_request.number }}" - }, - { - "type": "mrkdwn", - "text": "*Author*\n${{ github.event.pull_request.user.login }}" - } - ] - } - ] - } - ] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_PR_WEBHOOK_URL }} - SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK diff --git a/.github/workflows/opend-issue-notification.yml b/.github/workflows/opend-issue-notification.yml deleted file mode 100644 index 2204395bb..000000000 --- a/.github/workflows/opend-issue-notification.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Opend Issue Notification -on: - issues: - types: - - opened - -jobs: - create-issue: - name: Send opend issue notification to slack - runs-on: ubuntu-latest - steps: - - name: Send Issue - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - text: "*새로운 이슈가 생성되었습니다!*", - attachments: [{ - fallback: 'fallback', - color: '#1F7629', - title: 'Title', - text: '<${{ github.event.issue.html_url }}|${{ github.event.issue.title }}>', - fields: [{ - title: 'Issue number', - value: '#${{ github.event.issue.number }}', - short: true - }, - { - title: 'Author', - value: '${{ github.event.issue.user.login }}', - short: true - }], - actions: [{ - }] - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_ISSUE_WEBHOOK_URL }} - if: always() diff --git a/.github/workflows/opened-pr-notification.yml b/.github/workflows/opened-pr-notification.yml deleted file mode 100644 index 1ff6bba58..000000000 --- a/.github/workflows/opened-pr-notification.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Opened PR Notification -on: - pull_request: - branches: - - dev - - main - types: - - opened - -jobs: - create-issue: - name: PR opened notification to slack - runs-on: ubuntu-latest - steps: - - name: Send opened PR notification - uses: slackapi/slack-github-action@v1.24.0 - with: - payload: | - { - "text": "*새로운 PR이 생성되었습니다!*", - "attachments": [ - { - "color": "#1F7629", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Title*\n<${{ github.event.pull_request.html_url }}|${{ github.event.pull_request.title }}>" - } - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Base branch*\n${{ github.base_ref }}" - }, - { - "type": "mrkdwn", - "text": "*Compare branch*\n${{ github.head_ref }}" - }, - { - "type": "mrkdwn", - "text": "*PR number*\n#${{ github.event.pull_request.number }}" - }, - { - "type": "mrkdwn", - "text": "*Author*\n${{ github.event.pull_request.user.login }}" - } - ] - } - ] - } - ] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_PR_WEBHOOK_URL }} - SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK diff --git a/.gitmodules b/.gitmodules index 550533915..76d6e720b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "backend/src/main/resources/festago-config"] - path = backend/src/main/resources/festago-config +[submodule "backend/src/main/resources/config"] + path = backend/src/main/resources/config url = https://github.com/festago/festago-config.git diff --git a/README.md b/README.md index 4bd7a0b20..856c9024b 100644 --- a/README.md +++ b/README.md @@ -1 +1,73 @@ -# 2023-festa-go +# 페스타고, 대학 축제를 더욱 즐겁게! +> 대학 축제 줄서기 및 축제 정보 제공 서비스 "페스타고" + +![](https://github.com/woowacourse-teams/2023-festa-go/assets/71129059/55f0d73b-c032-4c15-9cdf-40a560af948f) + +다들 즐거워 하는 대학 축제이지만, 한 가지 걱정되는 점이 있습니다. 바로 ‘줄 서기’입니다. 티켓팅과 예매를 위한 지루한 줄서기 과정은 축제의 재미를 반감시키는 요인입니다. + +페스타고는 이러한 문제를 해결하여, 우리 모두가 대학 축제를 더 편리하게 즐기기 위해 만들어졌습니다. + +페스타고를 통해 티켓을 예매하기 위해 불편한 줄 서기 과정을 거칠 필요 없이 온라인으로 티켓을 예매하고, 복잡한 절차 없이 스마트폰의 QR 코드만으로 입장을 할 수 있습니다. + +image + +
+
+ +**▷ 📲 다운로드 |** [PlayStore](https://play.google.com/store/apps/details?id=com.festago.festago) + +**▷ 📝 팀블로그 |** [Festago 팀블로그](https://festago.github.io/) + +**▷ 📧 연락처 |** team.festago@gmail.com + +## Android +
+

프로젝트 아키텍처

+ +
+ +
+

기술 스택

+ +
+ +## Backend +
+

백엔드 인프라 아키텍처

+ +
+ + +
+

기술 스택

+ + + + + + + +
+ + + + + + + +
+
+ +## 🎉 축제 스태프를 소개합니다 + +|BackEnd|BackEnd|BackEnd|BackEnd|Android|Android|Android| +|:-:|:-:|:-:|:-:|:-:|:-:|:-:| +|![](https://avatars.githubusercontent.com/u/103228463?v=4&size=100)|![](https://avatars.githubusercontent.com/u/116627736?v=4&size=100)|![](https://avatars.githubusercontent.com/u/71129059?v=4&size=100)|![](https://avatars.githubusercontent.com/u/100915276?v=4&size=100)|![](https://avatars.githubusercontent.com/u/108349655?v=4&size=100)|![](https://avatars.githubusercontent.com/u/67777523?v=4&size=100)|![](https://avatars.githubusercontent.com/u/37167652?v=4&size=100)| +|[푸우](https://github.com/BGuga)|[글렌](https://github.com/seokjin8678)|[애쉬](https://github.com/xxeol2)|[오리](https://github.com/carsago)|[베르](https://github.com/SeongHoonC)|[해시](https://github.com/EmilyCh0)|[아크](https://github.com/re4rk)| + +
+ +## ⛔️ 공연 관람시 주의사항 +> 페스타고 팀의 그라운드 룰을 소개합니다. + +image diff --git a/android/festago/.editorconfig b/android/festago/.editorconfig new file mode 100644 index 000000000..eb5a61772 --- /dev/null +++ b/android/festago/.editorconfig @@ -0,0 +1,7 @@ + +root = true +[*.{kt,kts}] +ktlint_standard_annotation = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_string-template-indent = disabled +ktlint_standard_multiline-expression-wrapping = disabled diff --git a/android/festago/.idea/.gitignore b/android/festago/.idea/.gitignore index 26d33521a..8f00030d5 100644 --- a/android/festago/.idea/.gitignore +++ b/android/festago/.idea/.gitignore @@ -1,3 +1,5 @@ # Default ignored files /shelf/ /workspace.xml +# GitHub Copilot persisted chat sessions +/copilot/chatSessions diff --git a/android/festago/.idea/gradle.xml b/android/festago/.idea/gradle.xml index 72b4213c2..4ddc1b665 100644 --- a/android/festago/.idea/gradle.xml +++ b/android/festago/.idea/gradle.xml @@ -4,17 +4,22 @@ diff --git a/android/festago/.idea/misc.xml b/android/festago/.idea/misc.xml index e541a32a1..3b798c287 100644 --- a/android/festago/.idea/misc.xml +++ b/android/festago/.idea/misc.xml @@ -1,6 +1,6 @@ - + diff --git a/android/festago/app/build.gradle.kts b/android/festago/app/build.gradle.kts index c92b8aae2..9745bdb39 100644 --- a/android/festago/app/build.gradle.kts +++ b/android/festago/app/build.gradle.kts @@ -1,11 +1,7 @@ -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties - plugins { id("com.android.application") id("org.jetbrains.kotlin.android") - id("kotlin-parcelize") id("kotlin-kapt") - kotlin("plugin.serialization") version "1.8.22" id("com.google.gms.google-services") id("com.google.firebase.crashlytics") id("org.jlleitschuh.gradle.ktlint") @@ -20,14 +16,10 @@ android { applicationId = "com.festago.festago" minSdk = 28 targetSdk = 34 - versionCode = 3 - versionName = "1.0.1" + versionCode = 13 + versionName = "2.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField("String", "BASE_URL", getSecretKey("base_url")) - buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getSecretKey("kakao_native_app_key")) - resValue("string", "kakao_redirection_scheme", getSecretKey("kakao_redirection_scheme")) } buildFeatures { @@ -35,9 +27,14 @@ android { } buildTypes { + debug { + buildConfigField("Boolean", "DEBUG_MODE", "true") + } + release { isMinifyEnabled = true isShrinkResources = true + buildConfigField("Boolean", "DEBUG_MODE", "false") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", @@ -69,97 +66,10 @@ kapt { } dependencies { - // domain - implementation(project(":domain")) - - // android - implementation("androidx.core:core-ktx:1.10.1") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.9.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation(project(":data")) + implementation(project(":presentation")) // hilt implementation("com.google.dagger:hilt-android:2.44") kapt("com.google.dagger:hilt-android-compiler:2.44") - - // recyclerview - implementation("androidx.recyclerview:recyclerview:1.3.1-rc01") - - // lifecycle - implementation("androidx.lifecycle:lifecycle-extensions:2.2.0") - - // glide - implementation("com.github.bumptech.glide:glide:4.15.1") - - // okhttp3 - implementation("com.squareup.okhttp3:okhttp:4.11.0") - - // retrofit - implementation("com.squareup.retrofit2:retrofit:2.9.0") - - // kotlin-serialization - implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") - - // junit4 - testImplementation("junit:junit:4.13.2") - testImplementation("androidx.test.ext:junit:1.1.5") - testImplementation("androidx.test:runner:1.5.2") - - // assertJ - testImplementation("org.assertj:assertj-core:3.22.0") - - // android-test - testImplementation("androidx.arch.core:core-testing:2.2.0") - - // mock - testImplementation("io.mockk:mockk-android:1.13.5") - - // okhttp3-mockwebserver - implementation("com.squareup.okhttp3:mockwebserver:4.11.0") - - // espresso - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - - // coroutine - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") - - // viewModel - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") - implementation("androidx.activity:activity-ktx:1.7.2") - implementation("androidx.fragment:fragment-ktx:1.6.0") - - // zxing - implementation("com.journeyapps:zxing-android-embedded:4.3.0") - - // firebase - implementation(platform("com.google.firebase:firebase-bom:32.2.0")) - implementation("com.google.firebase:firebase-analytics-ktx") - implementation("com.google.firebase:firebase-crashlytics-ktx") - implementation("com.google.firebase:firebase-messaging-ktx:23.2.1") - - // swiperefreshlayout - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - - // kakao login - implementation("com.kakao.sdk:v2-user:2.12.0") - - // Encrypted SharedPreference - implementation("androidx.security:security-crypto-ktx:1.1.0-alpha06") - - // turbine - testImplementation("app.cash.turbine:turbine:1.0.0") - - // inApp Update - implementation("com.google.android.play:app-update-ktx:2.1.0") - - // splash - implementation("androidx.core:core-splashscreen:1.1.0-alpha02") -} - -fun getSecretKey(propertyKey: String): String { - return gradleLocalProperties(rootDir).getProperty(propertyKey) } diff --git a/android/festago/app/proguard-rules.pro b/android/festago/app/proguard-rules.pro index 2b3412c13..794f592b0 100644 --- a/android/festago/app/proguard-rules.pro +++ b/android/festago/app/proguard-rules.pro @@ -22,6 +22,11 @@ # https://developers.kakao.com/docs/latest/en/getting-started/sdk-android#configure-for-shrinking-and-obfuscation-(optional) -keep class com.kakao.sdk.**.model.* { ; } +-keep class * extends com.google.gson.TypeAdapter +-keep interface com.kakao.sdk.**.*Api + +#---------------------------------------- Parcelize +-keep @kotlinx.parcelize.Parcelize public class * #---------------------------------------- Retrofit # Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and diff --git a/android/festago/app/src/androidTest/java/com/festago/festago/presentation/ui/reservationcomplete/ReservationCompleteActivityTest.kt b/android/festago/app/src/androidTest/java/com/festago/festago/presentation/ui/reservationcomplete/ReservationCompleteActivityTest.kt deleted file mode 100644 index 867e40c6c..000000000 --- a/android/festago/app/src/androidTest/java/com/festago/festago/presentation/ui/reservationcomplete/ReservationCompleteActivityTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.festago.festago.presentation.ui.reservationcomplete - -import androidx.test.core.app.ApplicationProvider -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.ext.junit.rules.ActivityScenarioRule -import com.festago.festago.R -import org.junit.Rule -import org.junit.Test -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - -class ReservationCompleteActivityTest { - private val reservationComplete = ReservedTicketArg(1L, 123, LocalDateTime.now()) - - private val intent = - ReservationCompleteActivity.getIntent( - context = ApplicationProvider.getApplicationContext(), - reservationComplete = reservationComplete, - ) - - @get:Rule - val activityRule = ActivityScenarioRule(intent) - - @Test - fun 예약에_성공한_메세지를_확인한다() { - // given - - // when & then - onView(withId(R.id.tvReservationComplete)).check(matches(withText("예매에 성공했습니다!"))) - } - - @Test - fun 티켓_번호_문구가_표시된다() { - // given - - // when & then - onView(withId(R.id.tvTicketNumberPrompt)).check(matches(withText("나의 티켓 번호"))) - } - - @Test - fun 티켓_번호가_보인다() { - // given - - // when & then - onView(withId(R.id.tvReservationCompleteNumber)).check(matches(withText(reservationComplete.number.toString()))) - } - - @Test - fun 입장_가능_시간이_보인다() { - // given - val entryTime = reservationComplete.entryTime.format(DateTimeFormatter.ofPattern("HH:mm")) - - // when & then - onView(withId(R.id.tvEntryTime)).check(matches(withText("[입장 가능 시간] $entryTime"))) - } - - @Test - fun 축제_공연_날짜가_보인다() { - // given - val entryTime = - reservationComplete.entryTime.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")) - - // when & then - onView(withId(R.id.tvEntryDate)).check(matches(withText(entryTime))) - } -} diff --git a/android/festago/app/src/main/AndroidManifest.xml b/android/festago/app/src/main/AndroidManifest.xml index 140e91a45..bbe9127fe 100644 --- a/android/festago/app/src/main/AndroidManifest.xml +++ b/android/festago/app/src/main/AndroidManifest.xml @@ -10,70 +10,13 @@ android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" - android:icon="@mipmap/ic_festago_logo" + android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_festago_logo_round" + android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" - android:theme="@style/Theme.Festago" + android:theme="@style/Base.Theme.Festago" android:usesCleartextTraffic="true" - tools:targetApi="31"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + tools:targetApi="34"> diff --git a/android/festago/app/src/main/ic_festago_logo-playstore.png b/android/festago/app/src/main/ic_festago_logo-playstore.png deleted file mode 100644 index bc1cf755b..000000000 Binary files a/android/festago/app/src/main/ic_festago_logo-playstore.png and /dev/null differ diff --git a/android/festago/app/src/main/ic_festago_logo_playstore.png b/android/festago/app/src/main/ic_festago_logo_playstore.png new file mode 100644 index 000000000..ae1c8bc13 Binary files /dev/null and b/android/festago/app/src/main/ic_festago_logo_playstore.png differ diff --git a/android/festago/app/src/main/java/com/festago/festago/FestagoApplication.kt b/android/festago/app/src/main/java/com/festago/festago/FestagoApplication.kt index 8f07fa96d..f4010ac32 100644 --- a/android/festago/app/src/main/java/com/festago/festago/FestagoApplication.kt +++ b/android/festago/app/src/main/java/com/festago/festago/FestagoApplication.kt @@ -1,11 +1,6 @@ package com.festago.festago import android.app.Application -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import com.festago.festago.presentation.fcm.FcmMessageType -import com.kakao.sdk.common.KakaoSdk import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp @@ -13,22 +8,17 @@ class FestagoApplication : Application() { override fun onCreate() { super.onCreate() - initKakaoSdk() - initNotificationChannel() +// initNotificationChannel() } - private fun initKakaoSdk() { - KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) - } - - private fun initNotificationChannel() { - val channel = NotificationChannel( - FcmMessageType.ENTRY_ALERT.channelId, - getString(R.string.entry_alert_channel_name), - NotificationManager.IMPORTANCE_DEFAULT - ) - val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } +// private fun initNotificationChannel() { +// val channel = NotificationChannel( +// FcmMessageType.ENTRY_ALERT.channelId, +// getString(R.string.entry_alert_channel_name), +// NotificationManager.IMPORTANCE_DEFAULT, +// ) +// val notificationManager = +// getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager +// notificationManager.createNotificationChannel(channel) +// } } diff --git a/android/festago/app/src/main/java/com/festago/festago/analytics/AnalyticsHelper.kt b/android/festago/app/src/main/java/com/festago/festago/analytics/AnalyticsHelper.kt deleted file mode 100644 index dfdb0275d..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/analytics/AnalyticsHelper.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.festago.festago.analytics - -interface AnalyticsHelper { - fun logEvent(event: AnalyticsEvent) -} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/AnalyticsModule.kt b/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/AnalyticsModule.kt deleted file mode 100644 index 9434899ef..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/AnalyticsModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.festago.festago.data.di.singletonscope - -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.FirebaseAnalyticsHelper -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@InstallIn(SingletonComponent::class) -@Module -interface AnalyticsModule { - @Binds - @Singleton - fun bindsFirebaseAnalyticsHelper( - analyticsHelper: FirebaseAnalyticsHelper - ): AnalyticsHelper -} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt b/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt deleted file mode 100644 index 6d6a91e99..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.festago.festago.data.di.singletonscope - -import com.festago.festago.BuildConfig -import com.festago.festago.data.retrofit.AuthInterceptor -import com.festago.festago.repository.AuthRepository -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import retrofit2.Retrofit -import javax.inject.Qualifier -import javax.inject.Singleton - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class AuthOkHttpClientQualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class NormalRetrofitQualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class AuthRetrofitQualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class BaseUrlQualifier - -@InstallIn(SingletonComponent::class) -@Module -object ApiModule { - - @Provides - @Singleton - @AuthOkHttpClientQualifier - fun provideOkHttpClient(authRepository: AuthRepository): OkHttpClient = OkHttpClient - .Builder() - .addInterceptor(AuthInterceptor(authRepository)) - .build() - - @Provides - @Singleton - fun provideRetrofitConverterFactory(): retrofit2.Converter.Factory { - val json = Json { - ignoreUnknownKeys = true - } - return json.asConverterFactory("application/json".toMediaType()) - } - - @Provides - @Singleton - @NormalRetrofitQualifier - fun providesNormalRetrofit( - @BaseUrlQualifier baseUrl: String, - converterFactory: retrofit2.Converter.Factory, - ): Retrofit = Retrofit.Builder() - .baseUrl(baseUrl) - .addConverterFactory(converterFactory) - .build() - - @Provides - @Singleton - @AuthRetrofitQualifier - fun providesAuthRetrofit( - @BaseUrlQualifier baseUrl: String, - @AuthOkHttpClientQualifier okHttpClient: OkHttpClient, - converterFactory: retrofit2.Converter.Factory, - ): Retrofit = Retrofit.Builder() - .baseUrl(baseUrl) - .client(okHttpClient) - .addConverterFactory(converterFactory) - .build() - - @Provides - @Singleton - @BaseUrlQualifier - fun providesBaseUrl(): String = BuildConfig.BASE_URL -} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/repository/FestivalDefaultRepository.kt b/android/festago/app/src/main/java/com/festago/festago/data/repository/FestivalDefaultRepository.kt deleted file mode 100644 index 12326dd03..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/data/repository/FestivalDefaultRepository.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.festago.festago.data.repository - -import com.festago.festago.data.service.FestivalRetrofitService -import com.festago.festago.data.util.onSuccessOrCatch -import com.festago.festago.data.util.runCatchingResponse -import com.festago.festago.model.Festival -import com.festago.festago.model.Reservation -import com.festago.festago.repository.FestivalRepository -import javax.inject.Inject - -class FestivalDefaultRepository @Inject constructor( - private val festivalRetrofitService: FestivalRetrofitService, -) : FestivalRepository { - override suspend fun loadFestivals(): Result> = - runCatchingResponse { festivalRetrofitService.getFestivals() } - .onSuccessOrCatch { it.toDomain() } - - override suspend fun loadFestivalDetail(festivalId: Long): Result = - runCatchingResponse { festivalRetrofitService.getFestivalDetail(festivalId) } - .onSuccessOrCatch { it.toDomain() } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/repository/TicketDefaultRepository.kt b/android/festago/app/src/main/java/com/festago/festago/data/repository/TicketDefaultRepository.kt deleted file mode 100644 index 33ac652e5..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/data/repository/TicketDefaultRepository.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.festago.festago.data.repository - -import com.festago.festago.data.dto.ReservedTicketRequest -import com.festago.festago.data.service.TicketRetrofitService -import com.festago.festago.data.util.onSuccessOrCatch -import com.festago.festago.data.util.runCatchingResponse -import com.festago.festago.model.ReservedTicket -import com.festago.festago.model.Ticket -import com.festago.festago.model.TicketCode -import com.festago.festago.repository.TicketRepository -import javax.inject.Inject - -class TicketDefaultRepository @Inject constructor( - private val ticketRetrofitService: TicketRetrofitService, -) : TicketRepository { - - override suspend fun loadTicket(ticketId: Long): Result = - runCatchingResponse { ticketRetrofitService.getTicket(ticketId) } - .onSuccessOrCatch { it.toDomain() } - - override suspend fun loadCurrentTickets(): Result> = - runCatchingResponse { ticketRetrofitService.getCurrentTickets() } - .onSuccessOrCatch { it.toDomain() } - - override suspend fun loadTicketCode(ticketId: Long): Result = - runCatchingResponse { ticketRetrofitService.getTicketCode(ticketId) } - .onSuccessOrCatch { it.toDomain() } - - override suspend fun loadHistoryTickets(size: Int): Result> = - runCatchingResponse { ticketRetrofitService.getHistoryTickets(size) } - .onSuccessOrCatch { it.toDomain() } - - override suspend fun reserveTicket(ticketId: Int): Result = - runCatchingResponse { - ticketRetrofitService.postReserveTicket( - ReservedTicketRequest(ticketId), - ) - }.onSuccessOrCatch { it.toDomain() } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/service/FestivalRetrofitService.kt b/android/festago/app/src/main/java/com/festago/festago/data/service/FestivalRetrofitService.kt deleted file mode 100644 index 0626fb95c..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/data/service/FestivalRetrofitService.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.festago.festago.data.service - -import com.festago.festago.data.dto.FestivalsResponse -import com.festago.festago.data.dto.ReservationFestivalResponse -import retrofit2.Response -import retrofit2.http.GET -import retrofit2.http.Path - -interface FestivalRetrofitService { - @GET("/festivals") - suspend fun getFestivals(): Response - - @GET("/festivals/{festivalId}") - suspend fun getFestivalDetail( - @Path("festivalId") festivalId: Long, - ): Response -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/HomeActivity.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/HomeActivity.kt deleted file mode 100644 index 9b7fe0a8a..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/HomeActivity.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.festago.festago.presentation.ui.home - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.widget.Toast -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import com.festago.festago.R -import com.festago.festago.databinding.ActivityHomeBinding -import com.festago.festago.presentation.ui.home.festivallist.FestivalListFragment -import com.festago.festago.presentation.ui.home.mypage.MyPageFragment -import com.festago.festago.presentation.ui.home.ticketlist.TicketListFragment -import com.festago.festago.presentation.ui.signin.SignInActivity -import com.festago.festago.presentation.util.repeatOnStarted -import com.festago.festago.presentation.util.requestNotificationPermission -import com.google.android.material.navigation.NavigationBarView -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class HomeActivity : AppCompatActivity() { - - private val binding by lazy { ActivityHomeBinding.inflate(layoutInflater) } - - private val vm: HomeViewModel by viewModels() - - private lateinit var resultLauncher: ActivityResultLauncher - - private val navigationBarView by lazy { binding.nvHome as NavigationBarView } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - initBinding() - initView() - initObserve() - initResultLauncher() - } - - private fun initResultLauncher() { - resultLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == SignInActivity.RESULT_NOT_SIGN_IN) { - navigationBarView.selectedItemId = R.id.item_festival - } - } - initNotificationPermission() - } - - private fun initBinding() { - setContentView(binding.root) - } - - private fun initView() { - navigationBarView.setOnItemSelectedListener { - vm.selectItem(getItemType(it.itemId)) - true - } - - binding.fabTicket.setOnClickListener { - navigationBarView.selectedItemId = R.id.item_ticket - } - - changeFragment() - } - - private fun initObserve() { - repeatOnStarted(this) { - vm.event.collect { event -> - when (event) { - is HomeEvent.ShowSignIn -> showSignIn() - } - } - } - - repeatOnStarted(this) { - vm.selectedItem.collect { homeItemType -> - when (homeItemType) { - HomeItemType.FESTIVAL_LIST -> showFestivalList() - HomeItemType.TICKET_LIST -> showTicketList() - HomeItemType.MY_PAGE -> showMyPage() - } - } - } - } - - private fun initNotificationPermission() { - val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { isGranted: Boolean -> - if (!isGranted) { - Toast.makeText( - this, - getString(R.string.home_notification_permission_denied), - Toast.LENGTH_SHORT, - ).show() - } - } - requestNotificationPermission(requestPermissionLauncher) - } - - private fun getItemType(menuItemId: Int): HomeItemType { - return when (menuItemId) { - R.id.item_festival -> HomeItemType.FESTIVAL_LIST - R.id.item_mypage -> HomeItemType.MY_PAGE - R.id.item_ticket -> HomeItemType.TICKET_LIST - else -> throw IllegalArgumentException("menu item id not found") - } - } - - private fun showFestivalList() { - changeFragment() - binding.fabTicket.isSelected = false - } - - private fun showTicketList() { - changeFragment() - binding.fabTicket.isSelected = true - } - - private fun showMyPage() { - changeFragment() - binding.fabTicket.isSelected = false - } - - private fun showSignIn() { - resultLauncher.launch(SignInActivity.getIntent(this)) - } - - private inline fun changeFragment() { - val tag = T::class.java.name - val fragmentTransaction = supportFragmentManager.beginTransaction() - - supportFragmentManager.fragments.forEach { fragment -> - fragmentTransaction.hide(fragment) - } - - var targetFragment = supportFragmentManager.findFragmentByTag(tag) - - if (targetFragment == null) { - targetFragment = T::class.java.newInstance() - fragmentTransaction.add(R.id.fcv_home_container, targetFragment, tag) - } else { - fragmentTransaction.show(targetFragment) - } - - fragmentTransaction.commit() - } - - companion object { - fun getIntent(context: Context): Intent { - return Intent(context, HomeActivity::class.java) - } - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt deleted file mode 100644 index 04e701e44..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.festago.festago.presentation.ui.home.festivallist - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.GridLayoutManager -import com.festago.festago.R -import com.festago.festago.databinding.FragmentFestivalListBinding -import com.festago.festago.presentation.ui.home.ticketlist.TicketListFragment -import com.festago.festago.presentation.ui.ticketreserve.TicketReserveActivity -import com.festago.festago.presentation.util.repeatOnStarted -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class FestivalListFragment : Fragment(R.layout.fragment_festival_list) { - - private var _binding: FragmentFestivalListBinding? = null - private val binding get() = _binding!! - - private val vm: FestivalListViewModel by viewModels() - - private lateinit var adapter: FestivalListAdapter - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentFestivalListBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - initObserve() - initView() - } - - private fun initObserve() { - repeatOnStarted(viewLifecycleOwner) { - vm.uiState.collect { - binding.uiState = it - updateUi(it) - } - } - repeatOnStarted(viewLifecycleOwner) { - vm.event.collect { - handleEvent(it) - } - } - } - - private val Int.dp: Int get() = (this / resources.displayMetrics.density).toInt() - - private fun initView() { - adapter = FestivalListAdapter() - binding.rvFestivalList.adapter = adapter - - binding.rvFestivalList.layoutManager.apply { - if (this is GridLayoutManager) { - val spanSize = (resources.displayMetrics.widthPixels.dp / 160) - spanCount = when { - spanSize < 2 -> 2 - spanSize > 4 -> 4 - else -> spanSize - } - } - } - - vm.loadFestivals() - - binding.srlFestivalList.setOnRefreshListener { - vm.loadFestivals() - binding.srlFestivalList.isRefreshing = false - } - } - - private fun updateUi(uiState: FestivalListUiState) { - when (uiState) { - is FestivalListUiState.Loading, - is FestivalListUiState.Error, - -> Unit - - is FestivalListUiState.Success -> handleSuccess(uiState) - } - } - - private fun handleSuccess(uiState: FestivalListUiState.Success) { - adapter.submitList(uiState.festivals) - } - - private fun handleEvent(event: FestivalListEvent) { - when (event) { - is FestivalListEvent.ShowTicketReserve -> { - removeTicketListFragment() - startActivity(TicketReserveActivity.getIntent(requireContext(), event.festivalId)) - } - } - } - - private fun removeTicketListFragment() { - parentFragmentManager.findFragmentByTag(TicketListFragment::class.java.name)?.let { - parentFragmentManager.beginTransaction().remove(it).commit() - } - } - - override fun onDestroyView() { - _binding = null - super.onDestroyView() - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListUiState.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListUiState.kt deleted file mode 100644 index eda06b3a9..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListUiState.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.festago.festago.presentation.ui.home.festivallist - -sealed interface FestivalListUiState { - object Loading : FestivalListUiState - - data class Success( - val festivals: List - ) : FestivalListUiState { - val hasFestival get() = festivals.isNotEmpty() - } - - object Error : FestivalListUiState - - val shouldShowSuccess get() = this is Success && hasFestival - val shouldShowSuccessAndEmpty get() = this is Success && !hasFestival - val shouldShowLoading get() = this is Loading - val shouldShowError get() = this is Error -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt deleted file mode 100644 index 78d86dc88..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.festago.festago.presentation.ui.home.festivallist - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.logNetworkFailure -import com.festago.festago.presentation.ui.home.festivallist.FestivalListEvent.ShowTicketReserve -import com.festago.festago.repository.FestivalRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class FestivalListViewModel @Inject constructor( - private val festivalRepository: FestivalRepository, - private val analyticsHelper: AnalyticsHelper, -) : ViewModel() { - - private val _uiState = MutableStateFlow(FestivalListUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _event = MutableSharedFlow() - val event: SharedFlow = _event.asSharedFlow() - - fun loadFestivals() { - viewModelScope.launch { - festivalRepository.loadFestivals() - .onSuccess { - _uiState.value = FestivalListUiState.Success( - festivals = it.map { festival -> - FestivalItemUiState( - id = festival.id, - name = festival.name, - startDate = festival.startDate, - endDate = festival.endDate, - thumbnail = festival.thumbnail, - onFestivalDetail = ::showTicketReserve, - ) - }, - ) - }.onFailure { - _uiState.value = FestivalListUiState.Error - analyticsHelper.logNetworkFailure(KEY_LOAD_FESTIVALS_LOG, it.message.toString()) - } - } - } - - fun showTicketReserve(festivalId: Long) { - viewModelScope.launch { - _event.emit(ShowTicketReserve(festivalId)) - } - } - - companion object { - private const val KEY_LOAD_FESTIVALS_LOG = "load_festivals" - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt deleted file mode 100644 index 8859752bc..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.festago.festago.presentation.ui.home.mypage - -import android.app.AlertDialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.festago.festago.R -import com.festago.festago.databinding.FragmentMyPageBinding -import com.festago.festago.presentation.ui.home.HomeActivity -import com.festago.festago.presentation.ui.selectschool.SelectSchoolActivity -import com.festago.festago.presentation.ui.signin.SignInActivity -import com.festago.festago.presentation.ui.tickethistory.TicketHistoryActivity -import com.festago.festago.presentation.util.repeatOnStarted -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class MyPageFragment : Fragment(R.layout.fragment_my_page) { - - private var _binding: FragmentMyPageBinding? = null - private val binding get() = _binding!! - - private val vm: MyPageViewModel by viewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentMyPageBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - initObserve() - initView() - } - - private fun initObserve() { - repeatOnStarted(viewLifecycleOwner) { - vm.uiState.collect { uiState -> - handleUiState(uiState) - } - } - repeatOnStarted(viewLifecycleOwner) { - vm.event.collect { event -> - handleEvent(event) - } - } - } - - private fun handleUiState(uiState: MyPageUiState) { - binding.uiState = uiState - when (uiState) { - is MyPageUiState.Loading, is MyPageUiState.Error -> Unit - - is MyPageUiState.Success -> handleSuccess(uiState) - } - } - - private fun handleEvent(event: MyPageEvent) { - when (event) { - is MyPageEvent.ShowSignIn -> handleShowSignInEvent() - is MyPageEvent.SignOutSuccess -> handleSignOutSuccessEvent() - is MyPageEvent.DeleteAccountSuccess -> handleDeleteAccountSuccess() - is MyPageEvent.ShowTicketHistory -> handleShowTicketHistory() - is MyPageEvent.ShowConfirmDelete -> handleShowConfirmDelete() - } - } - - private fun handleShowSignInEvent() { - startActivity(SignInActivity.getIntent(requireContext())) - } - - private fun handleSignOutSuccessEvent() { - restartHome() - } - - private fun handleDeleteAccountSuccess() { - restartHome() - } - - private fun restartHome() { - requireActivity().finishAffinity() - startActivity(HomeActivity.getIntent(requireContext())) - } - - private fun handleShowTicketHistory() { - startActivity(TicketHistoryActivity.getIntent(requireContext())) - } - - private fun handleShowConfirmDelete() { - val dialog = AlertDialog.Builder(requireContext()).apply { - setTitle(getString(R.string.confirm_delete_dialog_title)) - setMessage(getString(R.string.confirm_delete_dialog_message)) - setPositiveButton(getString(R.string.confirm_delete_dialog_yes)) { dialog, _ -> - vm.deleteAccount() - dialog.dismiss() - } - setNegativeButton(getString(R.string.confirm_delete_dialog_no)) { dialog, _ -> - dialog.dismiss() - } - } - dialog.show() - } - - private fun initView() { - binding.vm = vm - - vm.loadUserInfo() - - binding.srlMyPage.setOnRefreshListener { - vm.loadUserInfo() - binding.srlMyPage.isRefreshing = false - } - - binding.tvSchoolAuthorization.setOnClickListener { - startActivity(SelectSchoolActivity.getIntent(requireContext())) - } - } - - private fun handleSuccess(uiState: MyPageUiState.Success) { - binding.successState = uiState - } - - override fun onDestroyView() { - _binding = null - super.onDestroyView() - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModel.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModel.kt deleted file mode 100644 index d1d91d66f..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModel.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.festago.festago.presentation.ui.home.mypage - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.logNetworkFailure -import com.festago.festago.repository.AuthRepository -import com.festago.festago.repository.TicketRepository -import com.festago.festago.repository.UserRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class MyPageViewModel @Inject constructor( - private val userRepository: UserRepository, - private val ticketRepository: TicketRepository, - private val authRepository: AuthRepository, - private val analyticsHelper: AnalyticsHelper, -) : ViewModel() { - - private val _uiState = MutableStateFlow(MyPageUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _event = MutableSharedFlow() - val event: SharedFlow = _event.asSharedFlow() - - fun loadUserInfo() { - if (!authRepository.isSigned) { - viewModelScope.launch { - _event.emit(MyPageEvent.ShowSignIn) - _uiState.value = MyPageUiState.Error - } - return - } - viewModelScope.launch { - val deferredUserProfile = async { userRepository.loadUserProfile() } - val deferredHistoryTicket = async { ticketRepository.loadHistoryTickets(size = 1) } - - runCatching { - _uiState.value = MyPageUiState.Success( - userProfile = deferredUserProfile.await().getOrThrow(), - ticket = deferredHistoryTicket.await().getOrThrow().firstOrNull(), - ) - }.onFailure { - _uiState.value = MyPageUiState.Error - analyticsHelper.logNetworkFailure( - key = KEY_LOAD_USER_INFO, - value = it.message.toString(), - ) - } - } - } - - fun signOut() { - viewModelScope.launch { - authRepository.signOut() - .onSuccess { - _event.emit(MyPageEvent.SignOutSuccess) - _uiState.value = MyPageUiState.Error - }.onFailure { - analyticsHelper.logNetworkFailure( - key = KEY_SIGN_OUT, - value = it.message.toString(), - ) - } - } - } - - fun showConfirmDelete() { - viewModelScope.launch { - _event.emit(MyPageEvent.ShowConfirmDelete) - } - } - - fun deleteAccount() { - viewModelScope.launch { - authRepository.deleteAccount() - .onSuccess { - _event.emit(MyPageEvent.DeleteAccountSuccess) - _uiState.value = MyPageUiState.Error - }.onFailure { - analyticsHelper.logNetworkFailure( - key = KEY_DELETE_ACCOUNT, - value = it.message.toString(), - ) - } - } - } - - fun showTicketHistory() { - viewModelScope.launch { - _event.emit(MyPageEvent.ShowTicketHistory) - } - } - - companion object { - private const val KEY_LOAD_USER_INFO = "loadUserInfo" - private const val KEY_SIGN_OUT = "KEY_SIGN_OUT" - private const val KEY_DELETE_ACCOUNT = "KEY_DELETE_ACCOUNT" - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListFragment.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListFragment.kt deleted file mode 100644 index b3fa56d5a..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListFragment.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.festago.festago.presentation.ui.home.ticketlist - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.festago.festago.R -import com.festago.festago.databinding.FragmentTicketListBinding -import com.festago.festago.presentation.ui.ticketentry.TicketEntryActivity -import com.festago.festago.presentation.util.repeatOnStarted -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class TicketListFragment : Fragment(R.layout.fragment_ticket_list) { - - private var _binding: FragmentTicketListBinding? = null - private val binding get() = _binding!! - - private lateinit var adapter: TicketListAdapter - - private lateinit var resultLauncher: ActivityResultLauncher - - private val vm: TicketListViewModel by viewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentTicketListBinding.inflate(inflater) - binding.lifecycleOwner = viewLifecycleOwner - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - initObserve() - initView() - initActivityResult() - } - - private fun initObserve() { - repeatOnStarted(viewLifecycleOwner) { - vm.uiState.collect { - binding.uiState = it - updateUi(it) - } - } - repeatOnStarted(viewLifecycleOwner) { - vm.event.collect { event -> - handleEvent(event) - } - } - } - - private fun updateUi(uiState: TicketListUiState) { - when (uiState) { - is TicketListUiState.Loading, - is TicketListUiState.Error, - -> Unit - - is TicketListUiState.Success -> { - adapter.submitList(uiState.tickets) - } - } - } - - private fun handleEvent(event: TicketListEvent) { - when (event) { - is TicketListEvent.ShowTicketEntry -> showTicketEntry(event) - } - } - - private fun showTicketEntry(event: TicketListEvent.ShowTicketEntry) { - resultLauncher.launch( - TicketEntryActivity.getIntent( - context = requireContext(), - ticketId = event.ticketId, - ), - ) - } - - private fun initActivityResult() { - resultLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == TicketEntryActivity.RESULT_OK) { - requireActivity().supportFragmentManager.beginTransaction() - .replace(R.id.fcv_home_container, TicketListFragment()).commit() - } - } - } - - private fun initView() { - adapter = TicketListAdapter() - binding.rvTicketList.adapter = adapter - vm.loadCurrentTickets() - initRefresh() - } - - private fun initRefresh() { - binding.srlTicketList.setOnRefreshListener { - vm.loadCurrentTickets() - binding.srlTicketList.isRefreshing = false - } - } - - override fun onDestroyView() { - _binding = null - super.onDestroyView() - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/signin/SignInActivity.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/signin/SignInActivity.kt deleted file mode 100644 index dcfa93055..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/signin/SignInActivity.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.festago.festago.presentation.ui.signin - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.style.ForegroundColorSpan -import androidx.activity.OnBackPressedCallback -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import com.festago.festago.R -import com.festago.festago.databinding.ActivitySignInBinding -import com.festago.festago.presentation.ui.customview.OkDialogFragment -import com.festago.festago.presentation.ui.home.HomeActivity -import com.festago.festago.presentation.util.repeatOnStarted -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class SignInActivity : AppCompatActivity() { - - private lateinit var binding: ActivitySignInBinding - - private val vm: SignInViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - initBinding() - initView() - initObserve() - } - - private fun initBinding() { - binding = ActivitySignInBinding.inflate(layoutInflater) - setContentView(binding.root) - } - - private fun initView() { - binding.lifecycleOwner = this - binding.vm = vm - initComment() - initBackPressed() - } - - private fun initBackPressed() { - val callback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - setResult(RESULT_NOT_SIGN_IN, intent) - finish() - } - } - this.onBackPressedDispatcher.addCallback(this, callback) - } - - private fun initObserve() { - repeatOnStarted(this) { - vm.event.collect { event -> - when (event) { - is SignInEvent.SignInSuccess -> handleSuccessEvent() - is SignInEvent.SignInFailure -> handleFailureEvent() - } - } - } - } - - private fun initComment() { - val spannableStringBuilder = SpannableStringBuilder( - getString(R.string.mypage_tv_signin_description), - ).apply { - setSpan( - ForegroundColorSpan(getColor(R.color.seed)), - COLOR_SPAN_START_INDEX, - COLOR_SPAN_END_INDEX, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - } - binding.tvLoginDescription.text = spannableStringBuilder - } - - private fun handleSuccessEvent() { - showHomeWithFinish() - } - - private fun handleFailureEvent() { - val dialog = OkDialogFragment.newInstance(FAILURE_SIGN_IN).apply { - listener = OkDialogFragment.OnClickListener { - showHomeWithFinish() - } - } - dialog.show(supportFragmentManager, OkDialogFragment::class.java.name) - } - - private fun showHomeWithFinish() { - val intent = HomeActivity.getIntent(this).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - } - finishAffinity() - startActivity(intent) - } - - companion object { - private const val COLOR_SPAN_START_INDEX = 0 - private const val COLOR_SPAN_END_INDEX = 4 - - private const val FAILURE_SIGN_IN = "로그인에 실패했습니다." - const val RESULT_NOT_SIGN_IN = 1 - fun getIntent(context: Context): Intent { - return Intent(context, SignInActivity::class.java) - } - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt deleted file mode 100644 index f9054058e..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.festago.festago.presentation.ui.signin - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.logNetworkFailure -import com.festago.festago.repository.AuthRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class SignInViewModel @Inject constructor( - private val authRepository: AuthRepository, - private val analyticsHelper: AnalyticsHelper, -) : ViewModel() { - - private val _event = MutableSharedFlow() - val event: SharedFlow = _event - - fun signIn() { - viewModelScope.launch { - authRepository.signIn().onSuccess { - _event.emit(SignInEvent.SignInSuccess) - }.onFailure { - _event.emit(SignInEvent.SignInFailure) - analyticsHelper.logNetworkFailure(KEY_SIGN_IN_LOG, it.message.toString()) - } - } - } - - companion object { - private const val KEY_SIGN_IN_LOG = "KEY_SIGN_IN_LOG" - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt deleted file mode 100644 index 71606d264..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.festago.festago.presentation.ui.splash - -import android.annotation.SuppressLint -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.appcompat.app.AlertDialog -import androidx.core.splashscreen.SplashScreen -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import com.festago.festago.R -import com.festago.festago.databinding.ActivitySplashBinding -import com.festago.festago.presentation.ui.home.HomeActivity -import com.google.android.play.core.appupdate.AppUpdateInfo -import com.google.android.play.core.appupdate.AppUpdateManagerFactory -import com.google.android.play.core.install.model.UpdateAvailability -import dagger.hilt.android.AndroidEntryPoint - -@SuppressLint("CustomSplashScreen") -@AndroidEntryPoint -class SplashActivity : ComponentActivity() { - - val binding by lazy { - ActivitySplashBinding.inflate(layoutInflater) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val splashScreen = installSplashScreen() - splashScreen.setKeepOnScreenCondition { true } - checkAppUpdate(splashScreen) - setContentView(binding.root) - } - - private fun checkAppUpdate(splashScreen: SplashScreen) { - val appUpdateManager = AppUpdateManagerFactory.create(this) - appUpdateManager.appUpdateInfo - .addOnSuccessListener { appUpdateInfo -> - handleOnSuccess(appUpdateInfo, splashScreen) - }.addOnFailureListener { - showHome() - } - } - - private fun handleOnSuccess(appUpdateInfo: AppUpdateInfo, splashScreen: SplashScreen) { - if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { - splashScreen.setKeepOnScreenCondition { false } - requestUpdate() - } else { - showHome() - } - } - - private fun showHome() { - startActivity(HomeActivity.getIntent(this)) - finish() - } - - private fun requestUpdate() { - AlertDialog.Builder(this).apply { - setTitle(getString(R.string.splash_app_update_request_dialog_title)) - setMessage(getString(R.string.splash_app_update_request_dialog_message)) - setNegativeButton(R.string.ok_dialog_btn_cancel) { _, _ -> - handleCancelUpdate() - } - setPositiveButton(R.string.ok_dialog_btn_ok) { _, _ -> - handleOkUpdate() - } - setCancelable(false) - }.show() - } - - private fun handleCancelUpdate() { - Toast.makeText( - this@SplashActivity, - getString(R.string.splash_app_update_denied), - Toast.LENGTH_SHORT, - ).show() - finish() - } - - private fun handleOkUpdate() { - navigateToAppStore() - finish() - } - - private fun navigateToAppStore() { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName"))) - finish() - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/util/Event.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/util/Event.kt deleted file mode 100644 index e9298dfbb..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/util/Event.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.festago.festago.presentation.util - -/** - * Used as a wrapper for data that is exposed via a LiveData that represents an event. - */ -open class Event(private val content: T) { - - var hasBeenHandled = false - private set // Allow external read but not write - - /** - * Returns the content and prevents its use again. - */ - fun getContentIfNotHandled(): T? { - return if (hasBeenHandled) { - null - } else { - hasBeenHandled = true - content - } - } - - /** - * Returns the content, even if it's already been handled. - */ - fun peekContent(): T = content -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/util/MutableSingleLiveData.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/util/MutableSingleLiveData.kt deleted file mode 100644 index 68236a79c..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/util/MutableSingleLiveData.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.festago.festago.presentation.util - -class MutableSingleLiveData : SingleLiveData { - - constructor() : super() - - constructor(value: T) : super(value) - - public override fun postValue(value: T) { - super.postValue(value) - } - - public override fun setValue(value: T) { - super.setValue(value) - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/util/ParcelizeUtil.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/util/ParcelizeUtil.kt deleted file mode 100644 index f32cd1e3e..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/util/ParcelizeUtil.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.festago.festago.presentation.util - -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.Parcelable - -@Suppress("DEPRECATION") -inline fun Bundle.getParcelableCompat(key: String): T? { - return if (Build.VERSION.SDK_INT >= 33) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) - } -} - -@Suppress("DEPRECATION") -inline fun Bundle.getParcelableArrayListCompat(key: String): ArrayList? { - return if (Build.VERSION.SDK_INT >= 33) { - getParcelableArrayList(key, T::class.java) - } else { - getParcelableArrayList(key) - } -} - -@Suppress("DEPRECATION") -inline fun Intent.getParcelableExtraCompat(key: String): T? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelableExtra(key, T::class.java) - } else { - getParcelableExtra(key) as? T - } -} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/util/SingleLiveData.kt b/android/festago/app/src/main/java/com/festago/festago/presentation/util/SingleLiveData.kt deleted file mode 100644 index 434744392..000000000 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/util/SingleLiveData.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.festago.festago.presentation.util - -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.MutableLiveData - -abstract class SingleLiveData { - - private val liveData = MutableLiveData>() - - protected constructor() - - protected constructor(value: T) { - liveData.value = Event(value) - } - - protected open fun setValue(value: T) { - liveData.value = Event(value) - } - - protected open fun postValue(value: T) { - liveData.postValue(Event(value)) - } - - fun getValue() = liveData.value?.peekContent() - - fun observe(owner: LifecycleOwner, onResult: (T) -> Unit) { - liveData.observe(owner) { it.getContentIfNotHandled()?.let(onResult) } - } - - fun observePeek(owner: LifecycleOwner, onResult: (T) -> Unit) { - liveData.observe(owner) { onResult(it.peekContent()) } - } -} diff --git a/android/festago/app/src/main/res/layout/fragment_festival_list.xml b/android/festago/app/src/main/res/layout/fragment_festival_list.xml deleted file mode 100644 index b4b4a20f3..000000000 --- a/android/festago/app/src/main/res/layout/fragment_festival_list.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/festago/app/src/main/res/layout/fragment_ticket_reserve_bottom_sheet.xml b/android/festago/app/src/main/res/layout/fragment_ticket_reserve_bottom_sheet.xml deleted file mode 100644 index f34d68337..000000000 --- a/android/festago/app/src/main/res/layout/fragment_ticket_reserve_bottom_sheet.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/festago/app/src/main/res/values/strings.xml b/android/festago/app/src/main/res/values/strings.xml deleted file mode 100644 index 35daf2b7f..000000000 --- a/android/festago/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,143 +0,0 @@ - - - 페스타고 - - - yyyy.MM.dd - - - 입장전 - 입장완료 - 외출중 - - - 재학생용 - 방문객용 - 기타 - - - 업데이트 알림 - 새로운 페스타고를 사용하기 위해 업데이트 해주세요. - 업데이트 후 정상 사용가능합니다. - 페스타고 실행 중 문제가 발생했습니다. 페스타고로 문의해주세요. - - - HH:mm 티켓 활성화 - 티켓 제시 - 입장 전 - 입장 완료 - 외출중 - - - 입장 전 - 입장 완료 - 외출중 - 티켓 조회 과정에 문제가 발생했습니다. - - - 축제 목록 - 티켓 목록 - 마이페이지 - 알림 권한을 거부했습니다 :( - - - %1s ~ %1s - yyyy.MM.dd - MM.dd (E) HH:mm - [라인업] - [예매 가능 티켓] - %1$s(%2$s/%3$s) - ", " - ⚠️ 재학생용 티켓예매를 위해 사전에 학교 인증이 필요합니다. - 축제 조회에 실패했습니다. - 티켓 예매 - (%1$s/%2$s) - 예매 하기 - MM월 dd일 HH:mm 오픈예정 - 로그인 후 예매 - - - yyyy.MM.dd - 진행중 - 지난축제 - 축제 조회에 실패했습니다. - 조회된 축제가 없습니다. - %s - %s - - - 티켓 목록 - yyyy.MM.dd HH:mm - HH:mm 티켓 활성화 - 입장하기 - %d번 - 무대 시작 시간 %s - 입장 시작 시간 %s - 사용할 수 있는 티켓이 없습니다 - 티켓 목록 조회에 실패했습니다. - - - 확인 - 취소 - - - 카카오로 로그인 - 페스타고로\n대학교 축제를 즐겨보세요. - - - 예매에 성공했습니다! - 나의 티켓 번호 - HH:mm - yyyy.MM.dd - [입장 가능 시간] %s - - - 계정 - 학교 인증 - 로그아웃 - 탈퇴하기 - 예매 목록 - [무대 시작 시간] - [입장 시작 시간] - [입장 번호] - [예매 일자] - 더보기 > - 예매 내역이 존재하지 않습니다. - yyyy.MM.dd. HH:mm - 마이페이지 정보 받아오기에 실패했습니다. - - - 예매 목록 - [무대 시작 시간] - [입장 시작 시간] - [입장 번호] - %d번 - [예매 일자] - 티켓 조회에 실패했습니다. - 조회된 티켓이 없습니다. - - - 정말 탈퇴하시겠어요? - 탈퇴 버튼 선택 시, 계정은 삭제되며 복구되지 않습니다. - 탈퇴 - 취소 - - - - 학교 이메일 - 인증 코드 - 인증 번호 받기 - 인증 번호 확인 - \@%s - mm:ss - 학교 정보 받아오기에 실패했습니다. - - - 다음 - 학교 선택 - 학교 목록 불러오기에 실패했습니다. - 학교 선택 - - - 공연 입장 알림 - - diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModelTest.kt b/android/festago/app/src/test/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModelTest.kt deleted file mode 100644 index b6a5582c7..000000000 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModelTest.kt +++ /dev/null @@ -1,229 +0,0 @@ -package com.festago.festago.presentation.ui.ticketreserve - -import app.cash.turbine.test -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.model.Reservation -import com.festago.festago.model.ReservationStage -import com.festago.festago.model.ReservationTicket -import com.festago.festago.model.ReservationTickets -import com.festago.festago.model.ReservedTicket -import com.festago.festago.model.TicketType -import com.festago.festago.repository.AuthRepository -import com.festago.festago.repository.FestivalRepository -import com.festago.festago.repository.ReservationTicketRepository -import com.festago.festago.repository.TicketRepository -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.assertj.core.api.AssertionsForClassTypes.assertThat -import org.assertj.core.api.SoftAssertions -import org.junit.After -import org.junit.Before -import org.junit.Test -import java.time.LocalDate -import java.time.LocalDateTime - -class TicketReserveViewModelTest { - private lateinit var vm: TicketReserveViewModel - private lateinit var reservationTicketRepository: ReservationTicketRepository - private lateinit var festivalRepository: FestivalRepository - private lateinit var ticketRepository: TicketRepository - private lateinit var authRepository: AuthRepository - private lateinit var analyticsHelper: AnalyticsHelper - - private val fakeReservationTickets = ReservationTickets( - listOf( - ReservationTicket(1, TicketType.STUDENT, 219, 500), - ReservationTicket(1, TicketType.VISITOR, 212, 300), - ), - ) - private val fakeReservationStage = ReservationStage( - id = 1, - lineUp = "르세라핌, 아이브, 뉴진스", - reservationTickets = fakeReservationTickets, - startTime = LocalDateTime.now(), - ticketOpenTime = LocalDateTime.now(), - ) - private val fakeReservationStages = List(5) { fakeReservationStage } - private val fakeReservation = Reservation( - id = 1, - name = "테코대학교", - reservationStages = fakeReservationStages, - startDate = LocalDate.now(), - endDate = LocalDate.now(), - thumbnail = "https://search2.kakaocdn.net/argon/656x0_80_wr/8vLywd3V06c", - ) - - private val fakeReservedTicket = ReservedTicket( - id = 1, - entryTime = LocalDateTime.now(), - number = 1, - ) - - @OptIn(ExperimentalCoroutinesApi::class) - @Before - fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) - reservationTicketRepository = mockk() - festivalRepository = mockk() - ticketRepository = mockk() - authRepository = mockk() - analyticsHelper = mockk(relaxed = true) - vm = TicketReserveViewModel( - reservationTicketRepository, - festivalRepository, - ticketRepository, - authRepository, - analyticsHelper, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun tearDown() { - Dispatchers.resetMain() - } - - private fun `예약 정보 요청 결과가 다음과 같을 때`(result: Result) { - coEvery { festivalRepository.loadFestivalDetail(any()) } returns result - } - - private fun `인증 여부가 다음과 같을 때`(isSigned: Boolean) { - coEvery { authRepository.isSigned } answers { isSigned } - } - - private fun `특정 공연의 티켓 타입 요청 결과가 다음과 같을 때`(result: Result) { - coEvery { reservationTicketRepository.loadTicketTypes(any()) } returns result - } - - private fun `티켓 예약 요청 결과가 다음과 같을 때`(result: Result) { - coEvery { ticketRepository.reserveTicket(any()) } returns result - } - - @Test - fun `예약 정보를 불러오면 성공 이벤트가 발생하고 리스트를 반환한다`() { - // given - `예약 정보 요청 결과가 다음과 같을 때`(Result.success(fakeReservation)) - `인증 여부가 다음과 같을 때`(true) - - // when - vm.loadReservation() - - // then - assertThat(vm.uiState.value).isInstanceOf(TicketReserveUiState.Success::class.java) - - // and - val festival = (vm.uiState.value as TicketReserveUiState.Success).festival - val expected = ReservationFestivalUiState( - id = festival.id, - name = festival.name, - thumbnail = festival.thumbnail, - endDate = festival.endDate, - startDate = festival.startDate, - ) - assertThat(festival).isEqualTo(expected) - } - - @Test - fun `예약 정보를 불러오는 것을 실패하면 에러 이벤트가 발생한다`() { - // given - `예약 정보 요청 결과가 다음과 같을 때`(Result.failure(Exception())) - - // when - vm.loadReservation(0) - - // then - assertThat(vm.uiState.value).isEqualTo(TicketReserveUiState.Error) - } - - @Test - fun `예약 정보를 불러오는 중이면 로딩 이벤트가 발생한다`() { - // given - coEvery { - festivalRepository.loadFestivalDetail(0) - } coAnswers { - delay(1000) - Result.success(fakeReservation) - } - - // when - vm.loadReservation() - - // then - assertThat(vm.uiState.value).isEqualTo(TicketReserveUiState.Loading) - } - - @Test - fun `특정 공연의 티켓 타입을 보여주는 이벤트가 발생하면 해당 공연의 티켓 타입을 보여준다`() = runTest { - // given - `특정 공연의 티켓 타입 요청 결과가 다음과 같을 때`(Result.success(fakeReservationTickets)) - `인증 여부가 다음과 같을 때`(true) - - vm.event.test { - // when - vm.showTicketTypes(1, LocalDateTime.MIN) - - // then - val softly = SoftAssertions().apply { - val event = awaitItem() - assertThat(event).isExactlyInstanceOf(TicketReserveEvent.ShowTicketTypes::class.java) - - // and - val actual = (event as? TicketReserveEvent.ShowTicketTypes)?.tickets - assertThat(actual).isEqualTo(fakeReservationTickets.sortedByTicketTypes()) - } - softly.assertAll() - } - } - - @Test - fun `특정 공연의 티켓 타입을 보여주는 것을 실패하면 에러 이벤트가 발생한다`() { - // given - `특정 공연의 티켓 타입 요청 결과가 다음과 같을 때`(Result.failure(Exception())) - `인증 여부가 다음과 같을 때`(true) - - // when - vm.showTicketTypes(1, LocalDateTime.MIN) - - // then - assertThat(vm.uiState.value).isEqualTo(TicketReserveUiState.Error) - } - - @Test - fun `티켓 유형을 선택하고 예약하면 예약 성공 이벤트가 발생한다`() = runTest { - // given - coEvery { - ticketRepository.reserveTicket(any()) - } answers { - Result.success(fakeReservedTicket) - } - - vm.event.test { - // when - vm.reserveTicket(0) - - // then - assertThat(awaitItem()).isExactlyInstanceOf(TicketReserveEvent.ReserveTicketSuccess::class.java) - } - } - - @Test - fun `티켓 유형을 선택하고 예약하는 것을 실패하면 예약 실패 이벤트가 발생한다`() = runTest { - // given - `티켓 예약 요청 결과가 다음과 같을 때`(Result.failure(Exception())) - - vm.event.test { - // when - vm.reserveTicket(0) - - // then - assertThat(awaitItem()).isExactlyInstanceOf(TicketReserveEvent.ReserveTicketFailed::class.java) - } - } -} diff --git a/android/festago/build.gradle.kts b/android/festago/build.gradle.kts index 649598d35..a39bf1aab 100644 --- a/android/festago/build.gradle.kts +++ b/android/festago/build.gradle.kts @@ -7,6 +7,8 @@ plugins { val kotlinVersion = "1.8.20" kotlin("android") version kotlinVersion apply false kotlin("jvm") version kotlinVersion apply false + kotlin("kapt") version kotlinVersion apply false + id("org.jlleitschuh.gradle.ktlint") version "11.1.0" apply false id("com.google.gms.google-services") version "4.3.15" apply false @@ -14,4 +16,12 @@ plugins { id("com.google.firebase.crashlytics") version "2.9.7" apply false id("com.google.dagger.hilt.android") version "2.44" apply false + + id("org.jetbrains.kotlin.plugin.serialization") version "1.8.10" apply false + + id("androidx.navigation.safeargs") version "2.5.3" apply false +} + +allprojects { + apply(plugin = "org.jlleitschuh.gradle.ktlint") } diff --git a/android/festago/common/.gitignore b/android/festago/common/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/android/festago/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/festago/common/build.gradle.kts b/android/festago/common/build.gradle.kts new file mode 100644 index 000000000..9fd20d703 --- /dev/null +++ b/android/festago/common/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") +} + +android { + namespace = "com.festago.festago.common" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + debug { + buildConfigField("Boolean", "DEBUG_MODE", "true") + } + release { + isMinifyEnabled = false + buildConfigField("Boolean", "DEBUG_MODE", "false") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +kotlin.jvmToolchain(17) + +dependencies { + // common + implementation(project(":domain")) + // hilt + implementation("com.google.dagger:hilt-android:2.44") + kapt("com.google.dagger:hilt-android-compiler:2.44") + + // kakao login + implementation("com.kakao.sdk:v2-user:2.12.0") + + // firebase + implementation(platform("com.google.firebase:firebase-bom:32.2.0")) + implementation("com.google.firebase:firebase-analytics-ktx") + implementation("com.google.firebase:firebase-crashlytics-ktx") + implementation("com.google.firebase:firebase-messaging-ktx:23.4.1") + implementation("com.google.firebase:firebase-config:21.6.3") +} diff --git a/android/festago/common/consumer-rules.pro b/android/festago/common/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/android/festago/common/proguard-rules.pro b/android/festago/common/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/android/festago/common/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/festago/common/src/main/AndroidManifest.xml b/android/festago/common/src/main/AndroidManifest.xml new file mode 100644 index 000000000..275af8650 --- /dev/null +++ b/android/festago/common/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/android/festago/app/src/main/java/com/festago/festago/analytics/AnalyticsEvent.kt b/android/festago/common/src/main/java/com/festago/festago/common/analytics/AnalyticsEvent.kt similarity index 77% rename from android/festago/app/src/main/java/com/festago/festago/analytics/AnalyticsEvent.kt rename to android/festago/common/src/main/java/com/festago/festago/common/analytics/AnalyticsEvent.kt index f002b199f..cafbe6a91 100644 --- a/android/festago/app/src/main/java/com/festago/festago/analytics/AnalyticsEvent.kt +++ b/android/festago/common/src/main/java/com/festago/festago/common/analytics/AnalyticsEvent.kt @@ -1,4 +1,4 @@ -package com.festago.festago.analytics +package com.festago.festago.common.analytics data class AnalyticsEvent( val type: String, diff --git a/android/festago/app/src/main/java/com/festago/festago/analytics/AnalyticsExtensions.kt b/android/festago/common/src/main/java/com/festago/festago/common/analytics/AnalyticsExtensions.kt similarity index 83% rename from android/festago/app/src/main/java/com/festago/festago/analytics/AnalyticsExtensions.kt rename to android/festago/common/src/main/java/com/festago/festago/common/analytics/AnalyticsExtensions.kt index 1bf3f4345..167fe97f0 100644 --- a/android/festago/app/src/main/java/com/festago/festago/analytics/AnalyticsExtensions.kt +++ b/android/festago/common/src/main/java/com/festago/festago/common/analytics/AnalyticsExtensions.kt @@ -1,4 +1,4 @@ -package com.festago.festago.analytics +package com.festago.festago.common.analytics fun AnalyticsHelper.logNetworkFailure(key: String, value: String) { logEvent( diff --git a/android/festago/common/src/main/java/com/festago/festago/common/analytics/AnalyticsHelper.kt b/android/festago/common/src/main/java/com/festago/festago/common/analytics/AnalyticsHelper.kt new file mode 100644 index 000000000..7fddbc6cb --- /dev/null +++ b/android/festago/common/src/main/java/com/festago/festago/common/analytics/AnalyticsHelper.kt @@ -0,0 +1,5 @@ +package com.festago.festago.common.analytics + +interface AnalyticsHelper { + fun logEvent(event: AnalyticsEvent) +} diff --git a/android/festago/app/src/main/java/com/festago/festago/analytics/FirebaseAnalyticsHelper.kt b/android/festago/common/src/main/java/com/festago/festago/common/analytics/FirebaseAnalyticsHelper.kt similarity index 89% rename from android/festago/app/src/main/java/com/festago/festago/analytics/FirebaseAnalyticsHelper.kt rename to android/festago/common/src/main/java/com/festago/festago/common/analytics/FirebaseAnalyticsHelper.kt index 393795bf6..05af4066a 100644 --- a/android/festago/app/src/main/java/com/festago/festago/analytics/FirebaseAnalyticsHelper.kt +++ b/android/festago/common/src/main/java/com/festago/festago/common/analytics/FirebaseAnalyticsHelper.kt @@ -1,4 +1,4 @@ -package com.festago.festago.analytics +package com.festago.festago.common.analytics import android.content.Context import android.os.Bundle @@ -7,7 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class FirebaseAnalyticsHelper @Inject constructor( - @ApplicationContext context: Context + @ApplicationContext context: Context, ) : AnalyticsHelper { private val firebaseAnalytics: FirebaseAnalytics by lazy { diff --git a/android/festago/common/src/main/java/com/festago/festago/common/analytics/di/AnalyticsModule.kt b/android/festago/common/src/main/java/com/festago/festago/common/analytics/di/AnalyticsModule.kt new file mode 100644 index 000000000..3fe5aa3a7 --- /dev/null +++ b/android/festago/common/src/main/java/com/festago/festago/common/analytics/di/AnalyticsModule.kt @@ -0,0 +1,19 @@ +package com.festago.festago.common.analytics.di + +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.FirebaseAnalyticsHelper +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +interface AnalyticsModule { + @Binds + @Singleton + fun bindsFirebaseAnalyticsHelper( + analyticsHelper: FirebaseAnalyticsHelper, + ): AnalyticsHelper +} diff --git a/android/festago/common/src/main/java/com/festago/festago/common/kakao/KakaoAuthorization.kt b/android/festago/common/src/main/java/com/festago/festago/common/kakao/KakaoAuthorization.kt new file mode 100644 index 000000000..d9a97983f --- /dev/null +++ b/android/festago/common/src/main/java/com/festago/festago/common/kakao/KakaoAuthorization.kt @@ -0,0 +1,86 @@ +package com.festago.festago.common.kakao + +import android.content.Context +import com.festago.festago.domain.model.nonce.NonceGenerator +import com.kakao.sdk.auth.TokenManagerProvider +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause +import com.kakao.sdk.user.UserApiClient +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class KakaoAuthorization @Inject constructor() { + + suspend fun getIdToken(context: Context): Result { + return runCatching { + val nonce = NonceGenerator().generate() + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + try { + loginWithKakaoTalk(context, nonce) + } catch (error: Throwable) { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) throw error + loginWithKakaoAccount(context, nonce) + } + } else { + loginWithKakaoAccount(context, nonce) + } + } + } + + private suspend fun loginWithKakaoTalk(context: Context, nonce: String?): String { + return suspendCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoTalk(context, nonce = nonce) { token, error -> + if (error != null) { + continuation.resumeWithException(error) + } else if (token != null) { + if (token.idToken != null) { + continuation.resume(token.idToken!!) + } else { + continuation.resumeWithException(RuntimeException("Failure get kakao id token")) + } + } else { + continuation.resumeWithException(RuntimeException("Failure get kakao access token")) + } + } + } + } + + private suspend fun loginWithKakaoAccount(context: Context, nonce: String?): String { + return suspendCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoAccount(context, nonce = nonce) { token, error -> + if (error != null) { + continuation.resumeWithException(error) + } else if (token != null) { + if (token.idToken != null) { + continuation.resume(token.idToken!!) + } else { + continuation.resumeWithException(RuntimeException("Failure get kakao id token")) + } + } else { + continuation.resumeWithException(RuntimeException("Failure get kakao access token")) + } + } + } + } + + suspend fun signOut(): Result { + UserApiClient.instance.logout {} + return Result.success(Unit) + } + + suspend fun deleteAccount(): Result { + return suspendCoroutine> { continuation -> + TokenManagerProvider.instance.manager.getToken()?.let { + UserApiClient.instance.unlink { error -> + if (error == null) { + continuation.resume(Result.success(Unit)) + } else { + continuation.resumeWithException(error) + } + } + } ?: continuation.resume(Result.success(Unit)) + } + } +} diff --git a/android/festago/data-legacy/.gitignore b/android/festago/data-legacy/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/android/festago/data-legacy/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/festago/data-legacy/build.gradle.kts b/android/festago/data-legacy/build.gradle.kts new file mode 100644 index 000000000..a40a9a331 --- /dev/null +++ b/android/festago/data-legacy/build.gradle.kts @@ -0,0 +1,90 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("kotlin-parcelize") + id("kotlinx-serialization") + id("kotlin-kapt") +} + +android { + namespace = "com.festago.festago.data" + compileSdk = 34 + + defaultConfig { + minSdk = 28 + + buildConfigField("String", "BASE_URL", getSecretKey("base_url")) + } + + buildTypes { + debug { + buildConfigField("Boolean", "DEBUG_MODE", "true") + } + + release { + isMinifyEnabled = false + buildConfigField("Boolean", "DEBUG_MODE", "false") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +kotlin.jvmToolchain(17) + +dependencies { + implementation(project(":domain-legacy")) + + // hilt + implementation("com.google.dagger:hilt-android:2.50") + implementation("com.google.firebase:firebase-messaging-ktx:23.4.0") + kapt("com.google.dagger:hilt-android-compiler:2.50") + + // okhttp3 + implementation("com.squareup.okhttp3:okhttp:4.11.0") + + // okhttp3-mockwebserver + implementation("com.squareup.okhttp3:mockwebserver:4.11.0") + + // kotlin-serialization + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") + + // Encrypted SharedPreference + implementation("androidx.security:security-crypto-ktx:1.1.0-alpha06") + + // kakao login + implementation("com.kakao.sdk:v2-user:2.12.0") + + // junit4 + testImplementation("junit:junit:4.13.2") + testImplementation("androidx.test.ext:junit:1.1.5") + testImplementation("androidx.test:runner:1.5.2") + + // assertJ + testImplementation("org.assertj:assertj-core:3.22.0") + + // coroutine + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") +} + +fun getSecretKey(propertyKey: String): String { + return gradleLocalProperties(rootDir).getProperty(propertyKey) +} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/MockWeb.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/MockWeb.kt similarity index 88% rename from android/festago/app/src/main/java/com/festago/festago/data/MockWeb.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/MockWeb.kt index 219ad3c2e..6c35516b2 100644 --- a/android/festago/app/src/main/java/com/festago/festago/data/MockWeb.kt +++ b/android/festago/data-legacy/src/main/java/com/festago/festago/data/MockWeb.kt @@ -12,7 +12,7 @@ class MockWeb { init { val thread = Thread { - mockWebServer.dispatcher = dispatcher + mockWebServer.dispatcher = com.festago.festago.data.MockWeb.Companion.dispatcher mockWebServer.url("/") url = mockWebServer.url("").toString() } @@ -30,7 +30,7 @@ class MockWeb { when { path.startsWith("/member-tickets") -> MockResponse() .setResponseCode(201) - .setBody(getQrCode()) + .setBody(com.festago.festago.data.MockWeb.Companion.getQrCode()) else -> MockResponse().setResponseCode(404) } @@ -43,21 +43,25 @@ class MockWeb { MockResponse() .setHeader("Content-Type", "application/json") .setResponseCode(200) - .setBody(getTicket(ticketId)) + .setBody( + com.festago.festago.data.MockWeb.Companion.getTicket( + ticketId + ) + ) } path.startsWith("/member-tickets") -> { MockResponse() .setHeader("Content-Type", "application/json") .setResponseCode(200) - .setBody(getTickets()) + .setBody(com.festago.festago.data.MockWeb.Companion.getTickets()) } path.startsWith("/festivals") -> { MockResponse() .setHeader("Content-Type", "application/json") .setResponseCode(200) - .setBody(getFestivals()) + .setBody(com.festago.festago.data.MockWeb.Companion.getFestivals()) } else -> MockResponse().setResponseCode(404) diff --git a/android/festago/app/src/main/java/com/festago/festago/data/datasource/TokenDataSource.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/datasource/TokenDataSource.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/datasource/TokenDataSource.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/datasource/TokenDataSource.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/datasource/TokenLocalDataSource.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/datasource/TokenLocalDataSource.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/datasource/TokenLocalDataSource.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/datasource/TokenLocalDataSource.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/ViewModelScopeModule.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/di/ViewModelScopeModule.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/di/ViewModelScopeModule.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/di/ViewModelScopeModule.kt diff --git a/android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt new file mode 100644 index 000000000..f839c885e --- /dev/null +++ b/android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt @@ -0,0 +1,83 @@ +package com.festago.festago.data.di.singletonscope + +import com.festago.festago.data.BuildConfig +import com.festago.festago.data.retrofit.AuthInterceptor +import com.festago.festago.repository.AuthRepository +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthOkHttpClientQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class NormalRetrofitQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthRetrofitQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class BaseUrlQualifier + +@InstallIn(SingletonComponent::class) +@Module +object ApiModule { + + @Provides + @Singleton + @AuthOkHttpClientQualifier + fun provideOkHttpClient(authRepository: AuthRepository): OkHttpClient = OkHttpClient + .Builder() + .addInterceptor(AuthInterceptor(authRepository)) + .build() + + @Provides + @Singleton + fun provideRetrofitConverterFactory(): retrofit2.Converter.Factory { + val json = Json { + ignoreUnknownKeys = true + } + return json.asConverterFactory("application/json".toMediaType()) + } + + @Provides + @Singleton + @NormalRetrofitQualifier + fun providesNormalRetrofit( + @BaseUrlQualifier baseUrl: String, + converterFactory: retrofit2.Converter.Factory, + ): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(converterFactory) + .build() + + @Provides + @Singleton + @AuthRetrofitQualifier + fun providesAuthRetrofit( + @BaseUrlQualifier baseUrl: String, + @AuthOkHttpClientQualifier okHttpClient: OkHttpClient, + converterFactory: retrofit2.Converter.Factory, + ): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(converterFactory) + .build() + + @Provides + @Singleton + @BaseUrlQualifier + fun providesBaseUrl(): String = BuildConfig.BASE_URL +} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/FirebaseModule.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/FirebaseModule.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/FirebaseModule.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/FirebaseModule.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/FestivalResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/FestivalResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/FestivalResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/FestivalResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/FestivalsResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/FestivalsResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/FestivalsResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/FestivalsResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/MemberTicketFestivalResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/MemberTicketFestivalResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/MemberTicketFestivalResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/MemberTicketFestivalResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/MemberTicketResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/MemberTicketResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/MemberTicketResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/MemberTicketResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/MemberTicketsResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/MemberTicketsResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/MemberTicketsResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/MemberTicketsResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/OauthRequest.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/OauthRequest.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/OauthRequest.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/OauthRequest.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/OauthTokenResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/OauthTokenResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/OauthTokenResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/OauthTokenResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/ReservationFestivalResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservationFestivalResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/ReservationFestivalResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservationFestivalResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/ReservationStageResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservationStageResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/ReservationStageResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservationStageResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/ReservationTicketResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservationTicketResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/ReservationTicketResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservationTicketResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/ReservationTicketsResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservationTicketsResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/ReservationTicketsResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservationTicketsResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/ReservedTicketRequest.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservedTicketRequest.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/ReservedTicketRequest.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservedTicketRequest.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/ReservedTicketResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservedTicketResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/ReservedTicketResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/ReservedTicketResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/SchoolResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/SchoolResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolsResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/SchoolsResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/SchoolsResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/SchoolsResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/SendVerificationRequest.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/SendVerificationRequest.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/SendVerificationRequest.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/SendVerificationRequest.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/StageResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/StageResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/StageResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/StageResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/TicketCodeDto.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/TicketCodeDto.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/TicketCodeDto.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/TicketCodeDto.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/UserProfileResponse.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/UserProfileResponse.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/UserProfileResponse.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/UserProfileResponse.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/dto/VerificationRequest.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/VerificationRequest.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/dto/VerificationRequest.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/dto/VerificationRequest.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/repository/AuthDefaultRepository.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/AuthDefaultRepository.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/repository/AuthDefaultRepository.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/AuthDefaultRepository.kt diff --git a/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/FestivalDefaultRepository.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/FestivalDefaultRepository.kt new file mode 100644 index 000000000..d9d999223 --- /dev/null +++ b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/FestivalDefaultRepository.kt @@ -0,0 +1,23 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.service.FestivalRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.model.Festival +import com.festago.festago.model.FestivalFilter +import com.festago.festago.model.Reservation +import com.festago.festago.repository.FestivalRepository +import javax.inject.Inject + +class FestivalDefaultRepository @Inject constructor( + private val festivalRetrofitService: FestivalRetrofitService, +) : FestivalRepository { + override suspend fun loadFestivals(festivalFilter: FestivalFilter): Result> = + runCatchingResponse { + festivalRetrofitService.getFestivals(festivalFilter.name) + }.onSuccessOrCatch { it.toDomain() } + + override suspend fun loadFestivalDetail(festivalId: Long): Result = + runCatchingResponse { festivalRetrofitService.getFestivalDetail(festivalId) } + .onSuccessOrCatch { it.toDomain() } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/repository/ReservationTicketDefaultRepository.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/ReservationTicketDefaultRepository.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/repository/ReservationTicketDefaultRepository.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/ReservationTicketDefaultRepository.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/repository/SchoolDefaultRepository.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/SchoolDefaultRepository.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/repository/SchoolDefaultRepository.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/SchoolDefaultRepository.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/repository/SocialAuthKakaoRepository.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/SocialAuthKakaoRepository.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/repository/SocialAuthKakaoRepository.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/SocialAuthKakaoRepository.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/repository/StudentVerificationDefaultRepository.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/StudentVerificationDefaultRepository.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/repository/StudentVerificationDefaultRepository.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/StudentVerificationDefaultRepository.kt diff --git a/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/TicketDefaultRepository.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/TicketDefaultRepository.kt new file mode 100644 index 000000000..43d9197aa --- /dev/null +++ b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/TicketDefaultRepository.kt @@ -0,0 +1,47 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.dto.ReservedTicketRequest +import com.festago.festago.data.service.TicketRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.model.ErrorCode +import com.festago.festago.model.ReservedTicket +import com.festago.festago.model.Ticket +import com.festago.festago.model.TicketCode +import com.festago.festago.repository.TicketRepository +import javax.inject.Inject + +class TicketDefaultRepository @Inject constructor( + private val ticketRetrofitService: TicketRetrofitService, +) : TicketRepository { + + override suspend fun loadTicket(ticketId: Long): Result = + runCatchingResponse { ticketRetrofitService.getTicket(ticketId) } + .onSuccessOrCatch { it.toDomain() } + + override suspend fun loadCurrentTickets(): Result> = + runCatchingResponse { ticketRetrofitService.getCurrentTickets() } + .onSuccessOrCatch { it.toDomain() } + + override suspend fun loadTicketCode(ticketId: Long): Result = + runCatchingResponse { ticketRetrofitService.getTicketCode(ticketId) } + .onSuccessOrCatch { it.toDomain() } + + override suspend fun loadHistoryTickets(size: Int): Result> = + runCatchingResponse { ticketRetrofitService.getHistoryTickets(size) } + .onSuccessOrCatch { it.toDomain() } + + override suspend fun reserveTicket(ticketId: Int): Result = + runCatchingResponse { ticketRetrofitService.postReserveTicket(ReservedTicketRequest(ticketId)) } + .onSuccessOrCatch { it.toDomain() } + .onFailure { throwable -> + val message = throwable.message ?: "ERROR_UNKNOWN" + val error: Throwable = when { + "NEED_STUDENT_VERIFICATION" in message -> ErrorCode.NEED_STUDENT_VERIFICATION() + "RESERVE_TICKET_OVER_AMOUNT" in message -> ErrorCode.RESERVE_TICKET_OVER_AMOUNT() + "TICKET_SOLD_OUT" in message -> ErrorCode.TICKET_SOLD_OUT() + else -> throwable + } + return Result.failure(error) + } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/repository/UserDefaultRepository.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/UserDefaultRepository.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/repository/UserDefaultRepository.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/repository/UserDefaultRepository.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt diff --git a/android/festago/data-legacy/src/main/java/com/festago/festago/data/service/FestivalRetrofitService.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/service/FestivalRetrofitService.kt new file mode 100644 index 000000000..551442889 --- /dev/null +++ b/android/festago/data-legacy/src/main/java/com/festago/festago/data/service/FestivalRetrofitService.kt @@ -0,0 +1,20 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.FestivalsResponse +import com.festago.festago.data.dto.ReservationFestivalResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface FestivalRetrofitService { + @GET("/festivals") + suspend fun getFestivals( + @Query("festivalFilter") festivalFilter: String, + ): Response + + @GET("/festivals/{festivalId}") + suspend fun getFestivalDetail( + @Path("festivalId") festivalId: Long, + ): Response +} diff --git a/android/festago/app/src/main/java/com/festago/festago/data/service/ReservationTicketRetrofitService.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/service/ReservationTicketRetrofitService.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/service/ReservationTicketRetrofitService.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/service/ReservationTicketRetrofitService.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/service/StudentVerificationRetrofitService.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/service/StudentVerificationRetrofitService.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/service/StudentVerificationRetrofitService.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/service/StudentVerificationRetrofitService.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/service/TicketRetrofitService.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/service/TicketRetrofitService.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/service/TicketRetrofitService.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/service/TicketRetrofitService.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/service/TokenRetrofitService.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/service/TokenRetrofitService.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/service/TokenRetrofitService.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/service/TokenRetrofitService.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/service/UserRetrofitService.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/service/UserRetrofitService.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/service/UserRetrofitService.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/service/UserRetrofitService.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/util/ResponseExt.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/util/ResponseExt.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/util/ResponseExt.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/util/ResponseExt.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/data/util/ResultExt.kt b/android/festago/data-legacy/src/main/java/com/festago/festago/data/util/ResultExt.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/data/util/ResultExt.kt rename to android/festago/data-legacy/src/main/java/com/festago/festago/data/util/ResultExt.kt diff --git a/android/festago/app/src/test/java/com/festago/festago/data/repository/ReservationTicketRetrofitServiceTest.kt b/android/festago/data-legacy/src/test/java/com/festago/festago/data/repository/ReservationTicketRetrofitServiceTest.kt similarity index 99% rename from android/festago/app/src/test/java/com/festago/festago/data/repository/ReservationTicketRetrofitServiceTest.kt rename to android/festago/data-legacy/src/test/java/com/festago/festago/data/repository/ReservationTicketRetrofitServiceTest.kt index 8863bb511..bc49a8c24 100644 --- a/android/festago/app/src/test/java/com/festago/festago/data/repository/ReservationTicketRetrofitServiceTest.kt +++ b/android/festago/data-legacy/src/test/java/com/festago/festago/data/repository/ReservationTicketRetrofitServiceTest.kt @@ -198,8 +198,7 @@ class ReservationTicketRetrofitServiceTest { startTime = "2023-07-09T16:00:00", ticketOpenTime = "2023-07-08T14:00:00", lineUp = "르세라핌,아이브,뉴진스", - tickets = - listOf( + tickets = listOf( ReservationTicketResponse( id = 1, ticketType = "STUDENT", diff --git a/android/festago/data/.gitignore b/android/festago/data/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/android/festago/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/festago/data/build.gradle.kts b/android/festago/data/build.gradle.kts new file mode 100644 index 000000000..065a0b329 --- /dev/null +++ b/android/festago/data/build.gradle.kts @@ -0,0 +1,101 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("kotlin-parcelize") + id("kotlinx-serialization") + id("kotlin-kapt") +} + +android { + namespace = "com.festago.festago.data" + compileSdk = 34 + + defaultConfig { + minSdk = 28 + + buildConfigField("String", "BASE_URL", getSecretKey("base_url")) + } + + buildTypes { + debug { + buildConfigField("Boolean", "DEBUG_MODE", "true") + } + + release { + isMinifyEnabled = false + buildConfigField("Boolean", "DEBUG_MODE", "false") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +kotlin.jvmToolchain(17) + +dependencies { + implementation(project(":domain")) + implementation(project(":common")) + + // hilt + implementation("com.google.dagger:hilt-android:2.50") + implementation("com.google.firebase:firebase-messaging-ktx:23.4.0") + kapt("com.google.dagger:hilt-android-compiler:2.50") + + // okhttp3 + implementation("com.squareup.okhttp3:okhttp:4.11.0") + + // okhttp3-mockwebserver + implementation("com.squareup.okhttp3:mockwebserver:4.11.0") + + // kotlin-serialization + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") + + // Encrypted SharedPreference + implementation("androidx.security:security-crypto-ktx:1.1.0-alpha06") + + // junit4 + testImplementation("junit:junit:4.13.2") + testImplementation("androidx.test.ext:junit:1.1.5") + testImplementation("androidx.test:runner:1.5.2") + + // assertJ + testImplementation("org.assertj:assertj-core:3.22.0") + + // coroutine + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") + + // room + val room_version = "2.6.1" + implementation("androidx.room:room-runtime:$room_version") + annotationProcessor("androidx.room:room-compiler:$room_version") + kapt("androidx.room:room-compiler:$room_version") + implementation("androidx.room:room-ktx:$room_version") + + // logging httpLoggingInterceptor + implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") + + // gson + implementation("com.google.code.gson:gson:2.9.0") +} + +fun getSecretKey(propertyKey: String): String { + return gradleLocalProperties(rootDir).getProperty(propertyKey) +} diff --git a/android/festago/data/consumer-rules.pro b/android/festago/data/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/android/festago/data/proguard-rules.pro b/android/festago/data/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/android/festago/data/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/festago/data/src/main/AndroidManifest.xml b/android/festago/data/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a5918e68a --- /dev/null +++ b/android/festago/data/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dao/RecentSearchQueryDao.kt b/android/festago/data/src/main/java/com/festago/festago/data/dao/RecentSearchQueryDao.kt new file mode 100644 index 000000000..611889f8c --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dao/RecentSearchQueryDao.kt @@ -0,0 +1,22 @@ +package com.festago.festago.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert +import com.festago.festago.data.model.RecentSearchQueryEntity + +@Dao +interface RecentSearchQueryDao { + @Query(value = "SELECT * FROM recentSearchQueries ORDER BY created_at DESC LIMIT :limit") + suspend fun getRecentSearchQueryEntities(limit: Int): List + + @Upsert + suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity) + + @Delete + suspend fun deleteRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity) + + @Query(value = "DELETE FROM recentSearchQueries") + suspend fun clearRecentSearchQueries() +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/database/FestagoDatabase.kt b/android/festago/data/src/main/java/com/festago/festago/data/database/FestagoDatabase.kt new file mode 100644 index 000000000..6db6158bf --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/database/FestagoDatabase.kt @@ -0,0 +1,11 @@ +package com.festago.festago.data.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.festago.festago.data.dao.RecentSearchQueryDao +import com.festago.festago.data.model.RecentSearchQueryEntity + +@Database(entities = [RecentSearchQueryEntity::class], version = 1) +abstract class FestagoDatabase : RoomDatabase() { + abstract fun recentSearchQueryDao(): RecentSearchQueryDao +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/bookmark/BookmarkDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/bookmark/BookmarkDataSource.kt new file mode 100644 index 000000000..290acc4ce --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/bookmark/BookmarkDataSource.kt @@ -0,0 +1,11 @@ +package com.festago.festago.data.datasource.bookmark + +import com.festago.festago.domain.model.bookmark.BookmarkType + +interface BookmarkDataSource { + fun addBookmark(id: Long, type: BookmarkType) + fun isBookmarked(id: Long, type: BookmarkType): Boolean + fun deleteBookmark(id: Long, type: BookmarkType) + fun getBookmarks(type: BookmarkType): List + fun setBookmarks(type: BookmarkType, ids: List) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/bookmark/DefaultBookMarkDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/bookmark/DefaultBookMarkDataSource.kt new file mode 100644 index 000000000..e42c639ee --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/bookmark/DefaultBookMarkDataSource.kt @@ -0,0 +1,30 @@ +package com.festago.festago.data.datasource.bookmark + +import com.festago.festago.domain.model.bookmark.BookmarkType +import javax.inject.Inject + +class DefaultBookMarkDataSource @Inject constructor() : BookmarkDataSource { + private val bookmarkStore = mutableMapOf>() + + override fun addBookmark(id: Long, type: BookmarkType) { + bookmarkStore[type] = (bookmarkStore[type] ?: listOf()) + id + } + + override fun isBookmarked(id: Long, type: BookmarkType): Boolean { + bookmarkStore[type]?.let { return it.contains(id) } + return false + } + + override fun deleteBookmark(id: Long, type: BookmarkType) { + val bookmarks = bookmarkStore[type] ?: return + bookmarkStore[type] = bookmarks - id + } + + override fun getBookmarks(type: BookmarkType): List { + return bookmarkStore[type] ?: listOf() + } + + override fun setBookmarks(type: BookmarkType, ids: List) { + bookmarkStore[type] = ids + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenDataSource.kt new file mode 100644 index 000000000..d61066b05 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenDataSource.kt @@ -0,0 +1,8 @@ +package com.festago.festago.data.datasource.token + +import com.festago.festago.data.model.TokenEntity + +interface TokenDataSource { + var accessToken: TokenEntity? + var refreshToken: TokenEntity? +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenLocalDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenLocalDataSource.kt new file mode 100644 index 000000000..27bffe848 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/token/TokenLocalDataSource.kt @@ -0,0 +1,43 @@ +package com.festago.festago.data.datasource.token + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.festago.festago.data.model.TokenEntity +import com.festago.festago.data.util.getObject +import com.festago.festago.data.util.putObject +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class TokenLocalDataSource @Inject constructor( + @ApplicationContext context: Context, +) : TokenDataSource { + + private val sharedPreference: SharedPreferences by lazy { + val masterKeyAlias = MasterKey + .Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + EncryptedSharedPreferences(context, ENCRYPTED_PREF_FILE, masterKeyAlias) + } + + override var accessToken: TokenEntity? + get() = sharedPreference.getObject(ACCESS_TOKEN_KEY, null) + set(value) { + sharedPreference.putObject(ACCESS_TOKEN_KEY, value) + } + + override var refreshToken: TokenEntity? + get() = sharedPreference.getObject(REFRESH_TOKEN_KEY, null) + set(value) { + sharedPreference.putObject(REFRESH_TOKEN_KEY, value) + } + + companion object { + private const val ENCRYPTED_PREF_FILE = "encrypted_pref_file" + private const val ACCESS_TOKEN_KEY = "access_token_key" + private const val REFRESH_TOKEN_KEY = "refresh_token_key" + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoDataSource.kt new file mode 100644 index 000000000..dc2f0dc2e --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoDataSource.kt @@ -0,0 +1,7 @@ +package com.festago.festago.data.datasource.userinfo + +import com.festago.festago.data.model.UserInfoEntity + +interface UserInfoDataSource { + var userInfo: UserInfoEntity? +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoLocalDataSource.kt b/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoLocalDataSource.kt new file mode 100644 index 000000000..2a9b3d9bb --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/datasource/userinfo/UserInfoLocalDataSource.kt @@ -0,0 +1,29 @@ +package com.festago.festago.data.datasource.userinfo + +import android.content.Context +import android.content.SharedPreferences +import com.festago.festago.data.model.UserInfoEntity +import com.festago.festago.data.util.getObject +import com.festago.festago.data.util.putObject +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class UserInfoLocalDataSource @Inject constructor( + @ApplicationContext context: Context, +) : UserInfoDataSource { + + private val sharedPreference: SharedPreferences by lazy { + context.getSharedPreferences(USER_INFO_PREF, Context.MODE_PRIVATE) + } + + override var userInfo: UserInfoEntity? + get() = sharedPreference.getObject(USER_ID_KEY, null) + set(value) { + sharedPreference.putObject(USER_ID_KEY, value) + } + + companion object { + private const val USER_INFO_PREF = "user_info_pref" + private const val USER_ID_KEY = "user_info_key" + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt new file mode 100644 index 000000000..ffe5fcb73 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ApiModule.kt @@ -0,0 +1,82 @@ +package com.festago.festago.data.di.singletonscope + +import com.festago.festago.data.BuildConfig +import com.festago.festago.data.retrofit.AuthInterceptor +import com.festago.festago.domain.repository.UserRepository +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class NormalRetrofitQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class BaseUrlQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthRetrofitQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthOkHttpClientQualifier + +@InstallIn(SingletonComponent::class) +@Module +object ApiModule { + @Provides + @Singleton + fun provideRetrofitConverterFactory(): retrofit2.Converter.Factory { + val json = Json { + ignoreUnknownKeys = true + } + return json.asConverterFactory("application/json".toMediaType()) + } + + @Provides + @Singleton + @AuthOkHttpClientQualifier + fun provideOkHttpClient(userRepository: UserRepository): OkHttpClient = OkHttpClient + .Builder() + .addInterceptor(AuthInterceptor(userRepository)) + .build() + + @Provides + @Singleton + @NormalRetrofitQualifier + fun providesNormalRetrofit( + @BaseUrlQualifier baseUrl: String, + converterFactory: retrofit2.Converter.Factory, + ): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(converterFactory) + .build() + + @Provides + @Singleton + @AuthRetrofitQualifier + fun providesAuthRetrofit( + @BaseUrlQualifier baseUrl: String, + @AuthOkHttpClientQualifier okHttpClient: OkHttpClient, + converterFactory: retrofit2.Converter.Factory, + ): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(converterFactory) + .build() + + @Provides + @Singleton + @BaseUrlQualifier + fun providesBaseUrl(): String = BuildConfig.BASE_URL +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DaosModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DaosModule.kt new file mode 100644 index 000000000..6ad5ca163 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DaosModule.kt @@ -0,0 +1,17 @@ +package com.festago.festago.data.di.singletonscope + +import com.festago.festago.data.dao.RecentSearchQueryDao +import com.festago.festago.data.database.FestagoDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object DaosModule { + + @Provides + fun providesRecentSearchQueryDao(database: FestagoDatabase): RecentSearchQueryDao = + database.recentSearchQueryDao() +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt new file mode 100644 index 000000000..20646927a --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DataSourceModule.kt @@ -0,0 +1,30 @@ +package com.festago.festago.data.di.singletonscope + +import com.festago.festago.data.datasource.bookmark.BookmarkDataSource +import com.festago.festago.data.datasource.bookmark.DefaultBookMarkDataSource +import com.festago.festago.data.datasource.token.TokenDataSource +import com.festago.festago.data.datasource.token.TokenLocalDataSource +import com.festago.festago.data.datasource.userinfo.UserInfoDataSource +import com.festago.festago.data.datasource.userinfo.UserInfoLocalDataSource +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface DataSourceModule { + + @Binds + @Singleton + fun bindsTokenDataSource(tokenDataSource: TokenLocalDataSource): TokenDataSource + + @Binds + @Singleton + fun bindsUserInfoDataSource(userInfoDataSource: UserInfoLocalDataSource): UserInfoDataSource + + @Binds + @Singleton + fun bindBookmarkDataSource(bookmarkDataSource: DefaultBookMarkDataSource): BookmarkDataSource +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DatabaseModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DatabaseModule.kt new file mode 100644 index 000000000..2c4e9c218 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/DatabaseModule.kt @@ -0,0 +1,25 @@ +package com.festago.festago.data.di.singletonscope + +import android.content.Context +import androidx.room.Room +import com.festago.festago.data.database.FestagoDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + @Provides + @Singleton + fun providesFestagoDatabase( + @ApplicationContext context: Context, + ): FestagoDatabase = Room.databaseBuilder( + context, + FestagoDatabase::class.java, + "festago-database", + ).build() +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt new file mode 100644 index 000000000..4d316faf7 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/RepositoryModule.kt @@ -0,0 +1,53 @@ +package com.festago.festago.data.di.singletonscope + +import com.festago.festago.data.repository.DefaultArtistRepository +import com.festago.festago.data.repository.DefaultBookmarkRepository +import com.festago.festago.data.repository.DefaultFestivalRepository +import com.festago.festago.data.repository.DefaultRecentSearchRepository +import com.festago.festago.data.repository.DefaultSchoolRepository +import com.festago.festago.data.repository.DefaultSearchRepository +import com.festago.festago.data.repository.DefaultUserRepository +import com.festago.festago.domain.repository.ArtistRepository +import com.festago.festago.domain.repository.BookmarkRepository +import com.festago.festago.domain.repository.FestivalRepository +import com.festago.festago.domain.repository.RecentSearchRepository +import com.festago.festago.domain.repository.SchoolRepository +import com.festago.festago.domain.repository.SearchRepository +import com.festago.festago.domain.repository.UserRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +interface RepositoryModule { + @Binds + @Singleton + fun bindsFestivalRepository(festivalRepository: DefaultFestivalRepository): FestivalRepository + + @Binds + @Singleton + fun bindsArtistRepository(artistRepository: DefaultArtistRepository): ArtistRepository + + @Binds + @Singleton + fun bindsSchoolRepository(schoolRepository: DefaultSchoolRepository): SchoolRepository + + @Binds + @Singleton + fun bindsRecentSearchRepository(recentSearchRepository: DefaultRecentSearchRepository): RecentSearchRepository + + @Binds + @Singleton + fun bindsSearchRepository(searchRepository: DefaultSearchRepository): SearchRepository + + @Binds + @Singleton + fun bindsBookmarkRepository(bookmarkRepository: DefaultBookmarkRepository): BookmarkRepository + + @Binds + @Singleton + fun bindsUserRepository(userRepository: DefaultUserRepository): UserRepository +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt new file mode 100644 index 000000000..a3414d88d --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/di/singletonscope/ServiceModule.kt @@ -0,0 +1,66 @@ +package com.festago.festago.data.di.singletonscope + +import com.festago.festago.data.service.ArtistRetrofitService +import com.festago.festago.data.service.AuthRetrofitService +import com.festago.festago.data.service.BookmarkRetrofitService +import com.festago.festago.data.service.FestivalRetrofitService +import com.festago.festago.data.service.SchoolRetrofitService +import com.festago.festago.data.service.SearchRetrofitService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object ServiceModule { + @Provides + @Singleton + fun providesFestivalRetrofitService( + @NormalRetrofitQualifier retrofit: Retrofit, + ): FestivalRetrofitService { + return retrofit.create(FestivalRetrofitService::class.java) + } + + @Provides + @Singleton + fun providesArtistRetrofitService( + @NormalRetrofitQualifier retrofit: Retrofit, + ): ArtistRetrofitService { + return retrofit.create(ArtistRetrofitService::class.java) + } + + @Provides + @Singleton + fun providesSchoolRetrofitService( + @NormalRetrofitQualifier retrofit: Retrofit, + ): SchoolRetrofitService { + return retrofit.create(SchoolRetrofitService::class.java) + } + + @Provides + @Singleton + fun providesSearchRetrofitService( + @NormalRetrofitQualifier retrofit: Retrofit, + ): SearchRetrofitService { + return retrofit.create(SearchRetrofitService::class.java) + } + + @Provides + @Singleton + fun providesBookmarkRetrofitService( + @AuthRetrofitQualifier retrofit: Retrofit, + ): BookmarkRetrofitService { + return retrofit.create(BookmarkRetrofitService::class.java) + } + + @Provides + @Singleton + fun providesAuthRetrofitService( + @NormalRetrofitQualifier retrofit: Retrofit, + ): AuthRetrofitService { + return retrofit.create(AuthRetrofitService::class.java) + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistDetailResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistDetailResponse.kt new file mode 100644 index 000000000..3025956a9 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistDetailResponse.kt @@ -0,0 +1,22 @@ +package com.festago.festago.data.dto.artist + +import com.festago.festago.data.dto.school.SocialMediaResponse +import com.festago.festago.domain.model.artist.ArtistDetail +import kotlinx.serialization.Serializable + +@Serializable +data class ArtistDetailResponse( + val id: Int, + val name: String?, + val profileImageUrl: String?, + val backgroundImageUrl: String?, + val socialMedias: List, +) { + fun toDomain() = ArtistDetail( + id = id, + artistName = name ?: "", + profileUrl = profileImageUrl ?: "", + backgroundUrl = backgroundImageUrl ?: "", + artistMedia = socialMedias.map { it.toDomain() }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistResponse.kt new file mode 100644 index 000000000..f87a1026d --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistResponse.kt @@ -0,0 +1,17 @@ +package com.festago.festago.data.dto.artist + +import com.festago.festago.domain.model.artist.Artist +import kotlinx.serialization.Serializable + +@Serializable +data class ArtistResponse( + val id: Long, + val name: String, + val profileImageUrl: String, +) { + fun toDomain() = Artist( + id = id, + name = name, + imageUrl = profileImageUrl, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistSearchResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistSearchResponse.kt new file mode 100644 index 000000000..99e4db4fb --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/artist/ArtistSearchResponse.kt @@ -0,0 +1,21 @@ +package com.festago.festago.data.dto.artist + +import com.festago.festago.domain.model.search.ArtistSearch +import kotlinx.serialization.Serializable + +@Serializable +data class ArtistSearchResponse( + val id: Long, + val name: String, + val profileImageUrl: String, + val todayStage: Int, + val plannedStage: Int, +) { + fun toDomain() = ArtistSearch( + id = id, + name = name, + profileImageUrl = profileImageUrl, + todayStage = todayStage, + upcomingStage = plannedStage, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/ArtistBookmarkInfoResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/ArtistBookmarkInfoResponse.kt new file mode 100644 index 000000000..f2fafb515 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/ArtistBookmarkInfoResponse.kt @@ -0,0 +1,17 @@ +package com.festago.festago.data.dto.bookmark + +import com.festago.festago.domain.model.bookmark.ArtistBookmarkInfo +import kotlinx.serialization.Serializable + +@Serializable +data class ArtistBookmarkInfoResponse( + val id: Long, + val name: String, + val profileImageUrl: String, +) { + fun toDomain() = ArtistBookmarkInfo( + id = id, + name = name, + profileImageUrl = profileImageUrl, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/ArtistBookmarkResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/ArtistBookmarkResponse.kt new file mode 100644 index 000000000..6c9673cb4 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/ArtistBookmarkResponse.kt @@ -0,0 +1,16 @@ +package com.festago.festago.data.dto.bookmark + +import com.festago.festago.domain.model.bookmark.ArtistBookmark +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +data class ArtistBookmarkResponse( + val artist: ArtistBookmarkInfoResponse, + val createdAt: String, +) { + fun toDomain() = ArtistBookmark( + artist = artist.toDomain(), + createdAt = LocalDateTime.parse(createdAt), + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/FestivalBookmarkResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/FestivalBookmarkResponse.kt new file mode 100644 index 000000000..c872ec11a --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/FestivalBookmarkResponse.kt @@ -0,0 +1,17 @@ +package com.festago.festago.data.dto.bookmark + +import com.festago.festago.data.dto.festival.FestivalResponse +import com.festago.festago.domain.model.bookmark.FestivalBookmark +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +class FestivalBookmarkResponse( + val festival: FestivalResponse, + val createdAt: String, +) { + fun toDomain() = FestivalBookmark( + festival.toDomain(), + createdAt = LocalDateTime.parse(createdAt), + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/SchoolBookmarkInfoResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/SchoolBookmarkInfoResponse.kt new file mode 100644 index 000000000..ee9f7686a --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/SchoolBookmarkInfoResponse.kt @@ -0,0 +1,17 @@ +package com.festago.festago.data.dto.bookmark + +import com.festago.festago.domain.model.bookmark.SchoolBookmarkInfo +import kotlinx.serialization.Serializable + +@Serializable +data class SchoolBookmarkInfoResponse( + val id: Long, + val name: String, + val logoUrl: String, +) { + fun toDomain() = SchoolBookmarkInfo( + id = id, + name = name, + logoUrl = logoUrl, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/SchoolBookmarkResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/SchoolBookmarkResponse.kt new file mode 100644 index 000000000..fc1233760 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/bookmark/SchoolBookmarkResponse.kt @@ -0,0 +1,16 @@ +package com.festago.festago.data.dto.bookmark + +import com.festago.festago.domain.model.bookmark.SchoolBookmark +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +data class SchoolBookmarkResponse( + val school: SchoolBookmarkInfoResponse, + val createdAt: String, +) { + fun toDomain() = SchoolBookmark( + school = school.toDomain(), + createdAt = LocalDateTime.parse(createdAt), + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalDetailResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalDetailResponse.kt new file mode 100644 index 000000000..9d8c55da7 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalDetailResponse.kt @@ -0,0 +1,31 @@ +package com.festago.festago.data.dto.festival + +import com.festago.festago.data.dto.school.SchoolResponse +import com.festago.festago.data.dto.school.SocialMediaResponse +import com.festago.festago.data.dto.stage.StageResponse +import com.festago.festago.domain.model.festival.FestivalDetail +import kotlinx.serialization.Serializable +import java.time.LocalDate + +@Serializable +data class FestivalDetailResponse( + val id: Long, + val name: String, + val startDate: String, + val endDate: String, + val posterImageUrl: String, + val school: SchoolResponse, + val socialMedias: List, + val stages: List, +) { + fun toDomain() = FestivalDetail( + id, + name, + LocalDate.parse(startDate), + LocalDate.parse(endDate), + posterImageUrl, + school.toDomain(), + socialMedias.map { it.toDomain() }, + stages.map { it.toDomain() }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalResponse.kt new file mode 100644 index 000000000..65ca92527 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalResponse.kt @@ -0,0 +1,28 @@ +package com.festago.festago.data.dto.festival + +import com.festago.festago.data.dto.artist.ArtistResponse +import com.festago.festago.data.dto.school.SchoolResponse +import com.festago.festago.domain.model.festival.Festival +import kotlinx.serialization.Serializable +import java.time.LocalDate + +@Serializable +data class FestivalResponse( + val id: Long, + val name: String, + val startDate: String, + val endDate: String, + val posterImageUrl: String, + val school: SchoolResponse? = null, + val artists: List, +) { + fun toDomain(): Festival = Festival( + id = id, + name = name, + startDate = LocalDate.parse(startDate), + endDate = LocalDate.parse(endDate), + imageUrl = posterImageUrl, + school = school?.toDomain(), + artists = artists.map { it.toDomain() }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalSearchResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalSearchResponse.kt new file mode 100644 index 000000000..acb3a9f52 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalSearchResponse.kt @@ -0,0 +1,25 @@ +package com.festago.festago.data.dto.festival + +import com.festago.festago.data.dto.artist.ArtistResponse +import com.festago.festago.domain.model.search.FestivalSearch +import kotlinx.serialization.Serializable +import java.time.LocalDate + +@Serializable +data class FestivalSearchResponse( + val id: Long, + val name: String, + val startDate: String, + val endDate: String, + val posterImageUrl: String, + val artists: List, +) { + fun toDomain(): FestivalSearch = FestivalSearch( + id = id, + name = name, + startDate = LocalDate.parse(startDate), + endDate = LocalDate.parse(endDate), + imageUrl = posterImageUrl, + artists = artists.map { it.toDomain() }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalsResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalsResponse.kt new file mode 100644 index 000000000..c382209ef --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/FestivalsResponse.kt @@ -0,0 +1,15 @@ +package com.festago.festago.data.dto.festival + +import com.festago.festago.domain.model.festival.FestivalsPage +import kotlinx.serialization.Serializable + +@Serializable +data class FestivalsResponse( + val last: Boolean, + val content: List, +) { + fun toDomain() = FestivalsPage( + isLastPage = last, + festivals = content.map { it.toDomain() }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/PopularFestivalsResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/PopularFestivalsResponse.kt new file mode 100644 index 000000000..10656ba9a --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/festival/PopularFestivalsResponse.kt @@ -0,0 +1,15 @@ +package com.festago.festago.data.dto.festival + +import com.festago.festago.domain.model.festival.PopularFestivals +import kotlinx.serialization.Serializable + +@Serializable +data class PopularFestivalsResponse( + val title: String, + val content: List, +) { + fun toDomain() = PopularFestivals( + title = title, + festivals = content.map { it.toDomain() }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolResponse.kt new file mode 100644 index 000000000..70d309900 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolResponse.kt @@ -0,0 +1,17 @@ +package com.festago.festago.data.dto.school + +import com.festago.festago.domain.model.school.School +import kotlinx.serialization.Serializable + +@Serializable +data class SchoolResponse( + val id: Long, + val name: String, + val profileImageUrl: String = "", +) { + fun toDomain() = School( + id = id, + name = name, + imageUrl = profileImageUrl, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolSearchResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolSearchResponse.kt new file mode 100644 index 000000000..a8ab759a7 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SchoolSearchResponse.kt @@ -0,0 +1,20 @@ +package com.festago.festago.data.dto.school + +import com.festago.festago.domain.model.search.SchoolSearch +import kotlinx.serialization.Serializable +import java.time.LocalDate + +@Serializable +data class SchoolSearchResponse( + val id: Long, + val name: String, + val logoUrl: String, + val upcomingFestivalStartDate: String?, +) { + fun toDomain() = SchoolSearch( + id = id, + name = name, + logoUrl = logoUrl, + upcomingFestivalStartDate = upcomingFestivalStartDate?.let { LocalDate.parse(it) }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SocialMediaResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SocialMediaResponse.kt new file mode 100644 index 000000000..1fb0650fb --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/school/SocialMediaResponse.kt @@ -0,0 +1,25 @@ +package com.festago.festago.data.dto.school + +import com.festago.festago.domain.model.social.SocialMedia +import com.festago.festago.domain.model.social.SocialMediaType +import kotlinx.serialization.Serializable + +@Serializable +data class SocialMediaResponse( + val type: String, + val name: String, + val logoUrl: String, + val url: String, +) { + fun toDomain(): SocialMedia { + val type = when (this.type) { + "FACEBOOK" -> SocialMediaType.FACEBOOK + "INSTAGRAM" -> SocialMediaType.INSTAGRAM + "YOUTUBE" -> SocialMediaType.YOUTUBE + "X" -> SocialMediaType.X + else -> SocialMediaType.NONE + } + + return SocialMedia(type = type, name = name, logoUrl = logoUrl, url = url) + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolFestivalArtistResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolFestivalArtistResponse.kt new file mode 100644 index 000000000..60c187114 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolFestivalArtistResponse.kt @@ -0,0 +1,17 @@ +package com.festago.festago.data.dto.schooldetail + +import com.festago.festago.domain.model.artist.Artist +import kotlinx.serialization.Serializable + +@Serializable +data class SchoolFestivalArtistResponse( + val id: Int, + val name: String, + val profileImageUrl: String, +) { + fun toDomain() = Artist( + id = id.toLong(), + name = name, + imageUrl = profileImageUrl, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolFestivalResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolFestivalResponse.kt new file mode 100644 index 000000000..aed20cd2d --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolFestivalResponse.kt @@ -0,0 +1,30 @@ +package com.festago.festago.data.dto.schooldetail + +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.model.school.School +import kotlinx.serialization.Serializable +import java.time.LocalDate + +@Serializable +data class SchoolFestivalResponse( + val id: Int, + val name: String, + val startDate: String, + val endDate: String, + val posterImageUrl: String, + val artists: List +) { + fun toDomain() = Festival( + id = id.toLong(), + name = name, + startDate = LocalDate.parse(startDate), + endDate = LocalDate.parse(endDate), + imageUrl = posterImageUrl, + school = School( + id = -1, + name = "", + imageUrl = "" + ), + artists = artists.map { it.toDomain() } + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolFestivalsResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolFestivalsResponse.kt new file mode 100644 index 000000000..f265f943b --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolFestivalsResponse.kt @@ -0,0 +1,15 @@ +package com.festago.festago.data.dto.schooldetail + +import com.festago.festago.domain.model.festival.FestivalsPage +import kotlinx.serialization.Serializable + +@Serializable +data class SchoolFestivalsResponse( + val last: Boolean, + val content: List +) { + fun toDomain() = FestivalsPage( + isLastPage = last, + festivals = content.map { it.toDomain() }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolInfoResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolInfoResponse.kt new file mode 100644 index 000000000..1f0d061be --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/schooldetail/SchoolInfoResponse.kt @@ -0,0 +1,22 @@ +package com.festago.festago.data.dto.schooldetail + +import com.festago.festago.data.dto.school.SocialMediaResponse +import com.festago.festago.domain.model.school.SchoolInfo +import kotlinx.serialization.Serializable + +@Serializable +data class SchoolInfoResponse( + val id: Int, + val name: String?, + val logoUrl: String?, + val backgroundImageUrl: String?, + val socialMedias: List, +) { + fun toDomain(): SchoolInfo = SchoolInfo( + id = id, + schoolName = name ?: "", + logoUrl = logoUrl ?: "", + backgroundUrl = backgroundImageUrl ?: "", + socialMedia = socialMedias.map { it.toDomain() } + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/stage/StageResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/stage/StageResponse.kt new file mode 100644 index 000000000..f3fc230bb --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/stage/StageResponse.kt @@ -0,0 +1,19 @@ +package com.festago.festago.data.dto.stage + +import com.festago.festago.data.dto.artist.ArtistResponse +import com.festago.festago.domain.model.stage.Stage +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +data class StageResponse( + val id: Long, + val startDateTime: String, + val artists: List, +) { + fun toDomain() = Stage( + id = id, + startDateTime = LocalDateTime.parse(startDateTime), + artists = artists.map { it.toDomain() }, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshRequest.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshRequest.kt new file mode 100644 index 000000000..7987ec816 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshRequest.kt @@ -0,0 +1,8 @@ +package com.festago.festago.data.dto.user + +import kotlinx.serialization.Serializable + +@Serializable +class RefreshRequest( + val refreshToken: String, +) diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshResponse.kt new file mode 100644 index 000000000..99415e01c --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/RefreshResponse.kt @@ -0,0 +1,9 @@ +package com.festago.festago.data.dto.user + +import kotlinx.serialization.Serializable + +@Serializable +data class RefreshResponse( + val accessToken: TokenResponse, + val refreshToken: TokenResponse, +) diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInRequest.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInRequest.kt new file mode 100644 index 000000000..3bfa725a5 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInRequest.kt @@ -0,0 +1,9 @@ +package com.festago.festago.data.dto.user + +import kotlinx.serialization.Serializable + +@Serializable +class SignInRequest( + val socialType: String, + val idToken: String, +) diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInResponse.kt new file mode 100644 index 000000000..dfdf7b582 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignInResponse.kt @@ -0,0 +1,11 @@ +package com.festago.festago.data.dto.user + +import kotlinx.serialization.Serializable + +@Serializable +data class SignInResponse( + val nickname: String, + val profileImageUrl: String, + val accessToken: TokenResponse, + val refreshToken: TokenResponse, +) diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignOutRequest.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignOutRequest.kt new file mode 100644 index 000000000..79b7c9979 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/SignOutRequest.kt @@ -0,0 +1,8 @@ +package com.festago.festago.data.dto.user + +import kotlinx.serialization.Serializable + +@Serializable +data class SignOutRequest( + val refreshToken: String, +) diff --git a/android/festago/data/src/main/java/com/festago/festago/data/dto/user/TokenResponse.kt b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/TokenResponse.kt new file mode 100644 index 000000000..b02033d36 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/dto/user/TokenResponse.kt @@ -0,0 +1,22 @@ +package com.festago.festago.data.dto.user + +import com.festago.festago.data.model.TokenEntity +import com.festago.festago.domain.model.user.Token +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +data class TokenResponse( + val token: String, + val expiredAt: String, +) { + fun toDomain() = Token( + token = token, + expiredAt = LocalDateTime.parse(expiredAt), + ) + + fun toEntity() = TokenEntity( + token = token, + expiredAt = expiredAt, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/model/RecentSearchQueryEntity.kt b/android/festago/data/src/main/java/com/festago/festago/data/model/RecentSearchQueryEntity.kt new file mode 100644 index 000000000..2076f4bbe --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/model/RecentSearchQueryEntity.kt @@ -0,0 +1,18 @@ +package com.festago.festago.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.festago.festago.domain.model.recentsearch.RecentSearchQuery + +@Entity( + tableName = "recentSearchQueries", +) +data class RecentSearchQueryEntity( + @PrimaryKey + val query: String, + @ColumnInfo(name = "created_at") + val createdAt: Long, +) { + fun toDomain() = RecentSearchQuery(query = query, queriedDate = createdAt) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/model/TokenEntity.kt b/android/festago/data/src/main/java/com/festago/festago/data/model/TokenEntity.kt new file mode 100644 index 000000000..598f2e426 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/model/TokenEntity.kt @@ -0,0 +1,14 @@ +package com.festago.festago.data.model + +import com.festago.festago.domain.model.user.Token +import java.time.LocalDateTime + +data class TokenEntity( + val token: String, + val expiredAt: String, +) { + fun toDomain() = Token( + token = token, + expiredAt = LocalDateTime.parse(expiredAt), + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/model/UserInfoEntity.kt b/android/festago/data/src/main/java/com/festago/festago/data/model/UserInfoEntity.kt new file mode 100644 index 000000000..f1cb00503 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/model/UserInfoEntity.kt @@ -0,0 +1,13 @@ +package com.festago.festago.data.model + +import com.festago.festago.domain.model.user.UserInfo + +data class UserInfoEntity( + val nickname: String, + val profileImageUrl: String, +) { + fun toDomain() = UserInfo( + nickname = nickname, + profileImageUrl = profileImageUrl, + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultArtistRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultArtistRepository.kt new file mode 100644 index 000000000..4fffcb6b8 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultArtistRepository.kt @@ -0,0 +1,40 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.service.ArtistRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.domain.model.artist.ArtistDetail +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.repository.ArtistRepository +import kotlinx.coroutines.delay +import java.time.LocalDate +import javax.inject.Inject + +class DefaultArtistRepository @Inject constructor( + private val artistRetrofitService: ArtistRetrofitService, +) : ArtistRepository { + + override suspend fun loadArtistDetail(id: Long, delayTimeMillis: Long): Result { + delay(delayTimeMillis) + return runCatchingResponse { artistRetrofitService.getArtistDetail(id) } + .onSuccessOrCatch { it.toDomain() } + } + + override suspend fun loadArtistFestivals( + id: Long, + size: Int?, + lastFestivalId: Long?, + lastStartDate: LocalDate?, + isPast: Boolean?, + ): Result { + return runCatchingResponse { + artistRetrofitService.getArtistFestivals( + artistId = id, + size = size, + lastFestivalId = lastFestivalId, + lastStartDate = lastStartDate, + isPast = isPast, + ) + }.onSuccessOrCatch { it.toDomain() } + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultBookmarkRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultBookmarkRepository.kt new file mode 100644 index 000000000..5a0ab4d13 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultBookmarkRepository.kt @@ -0,0 +1,134 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.datasource.bookmark.BookmarkDataSource +import com.festago.festago.data.service.BookmarkRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.domain.model.bookmark.ArtistBookmark +import com.festago.festago.domain.model.bookmark.BookmarkType +import com.festago.festago.domain.model.bookmark.FestivalBookmark +import com.festago.festago.domain.model.bookmark.FestivalBookmarkOrder +import com.festago.festago.domain.model.bookmark.SchoolBookmark +import com.festago.festago.domain.repository.BookmarkRepository +import javax.inject.Inject + +class DefaultBookmarkRepository @Inject constructor( + private val bookmarkRetrofitService: BookmarkRetrofitService, + private val bookmarkDataSource: BookmarkDataSource, +) : BookmarkRepository { + override suspend fun addFestivalBookmark(festivalId: Long): Result { + return runCatchingResponse { + bookmarkRetrofitService.addBookmark(festivalId, BookmarkType.FESTIVAL) + }.onSuccessOrCatch { + bookmarkDataSource.addBookmark(festivalId, BookmarkType.FESTIVAL) + } + } + + override suspend fun getFestivalBookmarks( + festivalIds: List, + festivalBookmarkOrder: FestivalBookmarkOrder, + ): Result> { + return runCatchingResponse { + bookmarkRetrofitService.getFestivalBookmarks( + festivalIds = festivalIds, + festivalBookmarkOrder = festivalBookmarkOrder, + ) + }.onSuccessOrCatch { response -> + response.map { festival -> festival.toDomain() }.also { festivalBookmarks -> + bookmarkDataSource.setBookmarks( + BookmarkType.FESTIVAL, + festivalBookmarks.map { it.festival.id }, + ) + } + } + } + + override suspend fun getFestivalBookmarkIds(): Result> { + return runCatchingResponse { + bookmarkRetrofitService.getFestivalBookmarkIds() + }.onSuccessOrCatch { it.also { festivalIds -> storeFestivalBookmarks(festivalIds) } } + } + + override suspend fun deleteFestivalBookmark(festivalId: Long): Result { + val result = runCatchingResponse { + bookmarkRetrofitService.deleteBookmark(festivalId, BookmarkType.FESTIVAL) + } + result.onSuccess { bookmarkDataSource.deleteBookmark(festivalId, BookmarkType.FESTIVAL) } + result.onFailure { if (it.message?.contains("204") == true) return Result.success(Unit) } + return result + } + + override suspend fun addSchoolBookmark(schoolId: Long): Result { + return runCatchingResponse { + bookmarkRetrofitService.addBookmark(schoolId, BookmarkType.SCHOOL) + }.onSuccessOrCatch { + bookmarkDataSource.addBookmark(schoolId, BookmarkType.SCHOOL) + } + } + + override suspend fun getSchoolBookmarks(): Result> { + return runCatchingResponse { + bookmarkRetrofitService.getSchoolBookmarks() + }.onSuccessOrCatch { response -> + response.map { schools -> schools.toDomain() } + .also { schoolBookmarks -> storeSchoolBookmarks(schoolBookmarks) } + } + } + + override suspend fun deleteSchoolBookmark(schoolId: Long): Result { + val result = runCatchingResponse { + bookmarkRetrofitService.deleteBookmark(schoolId, BookmarkType.SCHOOL) + } + result.onSuccess { bookmarkDataSource.deleteBookmark(schoolId, BookmarkType.SCHOOL) } + result.onFailure { if (it.message?.contains("204") == true) return Result.success(Unit) } + return result + } + + override suspend fun addArtistBookmark(artistId: Long): Result { + return runCatchingResponse { + bookmarkRetrofitService.addBookmark(artistId, BookmarkType.ARTIST) + }.onSuccessOrCatch { bookmarkDataSource.addBookmark(artistId, BookmarkType.ARTIST) } + } + + override suspend fun getArtistBookmarks(): Result> { + return runCatchingResponse { + bookmarkRetrofitService.getArtistBookmarks() + }.onSuccessOrCatch { + it.map { artist -> artist.toDomain() } + .also { artistBookmarks -> storeArtistBookmarks(artistBookmarks) } + }.onFailure { + it.printStackTrace() + } + } + + override suspend fun deleteArtistBookmark(artistId: Long): Result { + val result = runCatchingResponse { + bookmarkRetrofitService.deleteBookmark(artistId, BookmarkType.ARTIST) + } + result.onSuccess { bookmarkDataSource.deleteBookmark(artistId, BookmarkType.ARTIST) } + result.onFailure { if (it.message?.contains("204") == true) return Result.success(Unit) } + return result + } + + override fun isBookmarked(id: Long, type: BookmarkType): Boolean { + return bookmarkDataSource.isBookmarked(id, type) + } + + private fun storeSchoolBookmarks(schoolBookmarks: List) { + bookmarkDataSource.setBookmarks( + BookmarkType.SCHOOL, + schoolBookmarks.map { it.school.id }, + ) + } + + private fun storeArtistBookmarks(artistBookmarks: List) { + bookmarkDataSource.setBookmarks( + BookmarkType.ARTIST, + artistBookmarks.map { it.artist.id }, + ) + } + + private fun storeFestivalBookmarks(festivalBookmarks: List) { + bookmarkDataSource.setBookmarks(BookmarkType.FESTIVAL, festivalBookmarks) + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultFestivalRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultFestivalRepository.kt new file mode 100644 index 000000000..4f4902122 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultFestivalRepository.kt @@ -0,0 +1,53 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.service.FestivalRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.domain.model.festival.FestivalDetail +import com.festago.festago.domain.model.festival.FestivalFilter +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.model.festival.PopularFestivals +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.domain.repository.FestivalRepository +import kotlinx.coroutines.delay +import java.time.LocalDate +import javax.inject.Inject + +class DefaultFestivalRepository @Inject constructor( + private val festivalRetrofitService: FestivalRetrofitService, +) : FestivalRepository { + + override suspend fun loadPopularFestivals(): Result { + return runCatchingResponse { + festivalRetrofitService.getPopularFestivals() + }.onSuccessOrCatch { it.toDomain() } + } + + override suspend fun loadFestivals( + schoolRegion: SchoolRegion?, + festivalFilter: FestivalFilter?, + lastFestivalId: Long?, + lastStartDate: LocalDate?, + size: Int?, + ): Result { + return runCatchingResponse { + festivalRetrofitService.getFestivals( + region = schoolRegion?.name, + filter = festivalFilter?.name, + lastFestivalId = lastFestivalId, + lastStartDate = lastStartDate, + size = size, + ) + }.onSuccessOrCatch { it.toDomain() } + } + + override suspend fun loadFestivalDetail( + id: Long, + delayTimeMillis: Long, + ): Result { + delay(delayTimeMillis) + return runCatchingResponse { + festivalRetrofitService.getFestivalDetail(id) + }.onSuccessOrCatch { it.toDomain() } + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultRecentSearchRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultRecentSearchRepository.kt new file mode 100644 index 000000000..f6506e01c --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultRecentSearchRepository.kt @@ -0,0 +1,39 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.dao.RecentSearchQueryDao +import com.festago.festago.data.model.RecentSearchQueryEntity +import com.festago.festago.domain.model.recentsearch.RecentSearchQuery +import com.festago.festago.domain.repository.RecentSearchRepository +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class DefaultRecentSearchRepository @Inject constructor( + private val recentSearchQueryDao: RecentSearchQueryDao, +) : RecentSearchRepository { + + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { + recentSearchQueryDao.insertOrReplaceRecentSearchQuery( + RecentSearchQueryEntity( + query = searchQuery, + createdAt = System.currentTimeMillis(), + ), + ) + } + + override suspend fun deleteRecentSearch(searchQuery: String) { + recentSearchQueryDao.deleteRecentSearchQuery( + RecentSearchQueryEntity( + query = searchQuery, + createdAt = System.currentTimeMillis(), + ), + ) + } + + override suspend fun getRecentSearchQueries(limit: Int): List { + return recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { recentSearchQueries -> + recentSearchQueries.toDomain() + } + } + + override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries() +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultSchoolRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultSchoolRepository.kt new file mode 100644 index 000000000..0ee26cf46 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultSchoolRepository.kt @@ -0,0 +1,40 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.service.SchoolRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.model.school.SchoolInfo +import com.festago.festago.domain.repository.SchoolRepository +import kotlinx.coroutines.delay +import java.time.LocalDate +import javax.inject.Inject + +class DefaultSchoolRepository @Inject constructor( + private val schoolRetrofitService: SchoolRetrofitService, +) : SchoolRepository { + override suspend fun loadSchoolInfo(schoolId: Long, delayTimeMillis: Long): Result { + delay(delayTimeMillis) + return runCatchingResponse { + schoolRetrofitService.getSchool(schoolId) + }.onSuccessOrCatch { it.toDomain() } + } + + override suspend fun loadSchoolFestivals( + schoolId: Long, + size: Int?, + isPast: Boolean?, + lastFestivalId: Int?, + lastStartDate: LocalDate?, + ): Result { + return runCatchingResponse { + schoolRetrofitService.getSchoolFestivals( + schoolId = schoolId, + size = size, + isPast = isPast, + lastFestivalId = lastFestivalId, + lastStartDate = lastStartDate, + ) + }.onSuccessOrCatch { it.toDomain() } + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultSearchRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultSearchRepository.kt new file mode 100644 index 000000000..0dbc1db36 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultSearchRepository.kt @@ -0,0 +1,40 @@ +package com.festago.festago.data.repository + +import com.festago.festago.data.service.SearchRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.domain.model.search.ArtistSearch +import com.festago.festago.domain.model.search.FestivalSearch +import com.festago.festago.domain.model.search.SchoolSearch +import com.festago.festago.domain.repository.SearchRepository +import javax.inject.Inject + +class DefaultSearchRepository @Inject constructor( + private val searchRetrofitService: SearchRetrofitService, +) : SearchRepository { + + override suspend fun searchFestivals(searchQuery: String): Result> { + return runCatchingResponse { searchRetrofitService.searchFestivals(searchQuery) }.onSuccessOrCatch { festivalResponses -> + festivalResponses.map { it.toDomain() } + } + } + + override suspend fun searchArtists(searchQuery: String): Result> { + return runCatchingResponse { + searchRetrofitService.searchArtists(searchQuery) + }.onSuccessOrCatch { artistSearchResponses -> artistSearchResponses.map { it.toDomain() } } + } + + override suspend fun searchSchools(searchQuery: String): Result> { + if (searchQuery.length <= MIN_SCHOOL_SEARCH_QUERY_LENGTH) { + return Result.success(emptyList()) + } + return runCatchingResponse { + searchRetrofitService.searchSchools(searchQuery) + }.onSuccessOrCatch { schoolSearchResponses -> schoolSearchResponses.map { it.toDomain() } } + } + + companion object { + const val MIN_SCHOOL_SEARCH_QUERY_LENGTH = 1 + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultUserRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultUserRepository.kt new file mode 100644 index 000000000..7b286f7f4 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/DefaultUserRepository.kt @@ -0,0 +1,131 @@ +package com.festago.festago.data.repository + +import android.content.Context +import android.content.SharedPreferences +import com.festago.festago.common.kakao.KakaoAuthorization +import com.festago.festago.data.datasource.token.TokenDataSource +import com.festago.festago.data.datasource.userinfo.UserInfoDataSource +import com.festago.festago.data.dto.user.RefreshRequest +import com.festago.festago.data.dto.user.SignInRequest +import com.festago.festago.data.dto.user.SignOutRequest +import com.festago.festago.data.model.UserInfoEntity +import com.festago.festago.data.service.AuthRetrofitService +import com.festago.festago.data.util.onSuccessOrCatch +import com.festago.festago.data.util.runCatchingResponse +import com.festago.festago.domain.model.user.Token +import com.festago.festago.domain.model.user.UserInfo +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class DefaultUserRepository @Inject constructor( + private val authRetrofitService: AuthRetrofitService, + private val tokenDataSource: TokenDataSource, + private val kakaoAuthorization: KakaoAuthorization, + private val userInfoDataSource: UserInfoDataSource, + @ApplicationContext context: Context, +) : UserRepository { + + private val authPref: SharedPreferences by lazy { + context.getSharedPreferences(AUTH_PREF, Context.MODE_PRIVATE) + } + + override suspend fun isSigned() = getRefreshToken().isSuccess + + override suspend fun isSignRejected() = authPref.getBoolean(IS_SIGN_REJECTED, false) + + override suspend fun getAccessToken(): Result { + val token = tokenDataSource.accessToken?.toDomain() + ?: return Result.failure(NullPointerException("Access token is null")) + + if (!token.isExpired()) { + return Result.success(token) + } + + return try { + val refreshToken = getRefreshToken().getOrThrow() + refresh(refreshToken).getOrThrow() + Result.success(tokenDataSource.accessToken?.toDomain()!!) + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getRefreshToken(): Result { + val refreshToken = tokenDataSource.refreshToken?.toDomain() + ?: return Result.failure(NullPointerException("Refresh token is null")) + + if (refreshToken.isExpired()) { + return Result.failure(Exception("Refresh token is expired")) + } + + return Result.success(refreshToken) + } + + override suspend fun signIn(idToken: String): Result { + return runCatchingResponse { + authRetrofitService.signIn(SignInRequest(SOCIAL_TYPE, idToken)) + }.onSuccessOrCatch { signInResponse -> + userInfoDataSource.userInfo = + UserInfoEntity(signInResponse.nickname, signInResponse.profileImageUrl) + tokenDataSource.accessToken = signInResponse.accessToken.toEntity() + tokenDataSource.refreshToken = signInResponse.refreshToken.toEntity() + } + } + + override suspend fun rejectSignIn() { + if (isSigned() || isSignRejected()) return + authPref.edit().putBoolean(IS_SIGN_REJECTED, true).apply() + } + + override suspend fun signOut(): Result { + return runCatchingResponse { + authRetrofitService.signOut( + AUTHORIZATION_TOKEN_FORMAT.format(getAccessToken().getOrThrow().token), + SignOutRequest(getRefreshToken().getOrThrow().token), + ) + }.onSuccessOrCatch { + kakaoAuthorization.signOut() + clearToken() + } + } + + override suspend fun deleteAccount(): Result { + return runCatchingResponse { + authRetrofitService.deleteAccount( + AUTHORIZATION_TOKEN_FORMAT.format(getAccessToken().getOrThrow().token), + ) + }.onSuccessOrCatch { + kakaoAuthorization.deleteAccount() + clearToken() + } + } + + private suspend fun refresh(refreshToken: Token): Result { + return runCatchingResponse { + val refreshRequest = RefreshRequest(refreshToken.token) + clearToken() + authRetrofitService.refresh(refreshRequest) + }.onSuccessOrCatch { refreshTokenResponse -> + tokenDataSource.accessToken = refreshTokenResponse.accessToken.toEntity() + tokenDataSource.refreshToken = refreshTokenResponse.refreshToken.toEntity() + } + } + + override suspend fun getUserInfo(): Result { + return userInfoDataSource.userInfo?.toDomain()?.let { Result.success(it) } + ?: Result.failure(NullPointerException("User info is null")) + } + + override suspend fun clearToken() { + tokenDataSource.accessToken = null + tokenDataSource.refreshToken = null + } + + companion object { + private const val SOCIAL_TYPE = "KAKAO" + private const val AUTH_PREF = "auth_pref" + private const val IS_SIGN_REJECTED = "is_sign_rejected" + private const val AUTHORIZATION_TOKEN_FORMAT = "Bearer %s" + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeArtistRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeArtistRepository.kt new file mode 100644 index 000000000..e704cb514 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeArtistRepository.kt @@ -0,0 +1,105 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.artist.Artist +import com.festago.festago.domain.model.artist.ArtistDetail +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.model.school.School +import com.festago.festago.domain.model.social.SocialMedia +import com.festago.festago.domain.model.social.SocialMediaType +import com.festago.festago.domain.repository.ArtistRepository +import java.time.LocalDate +import javax.inject.Inject + +class FakeArtistRepository @Inject constructor() : ArtistRepository { + var index = 0 + + override suspend fun loadArtistDetail(id: Long, delayTimeMillis: Long): Result = + Result.success( + ArtistDetail( + 1, + "뉴진스${index++}", + "https://static.wikia.nocookie.net/witchers/images/d/d9/New_Jeans_Cover.png/revision/latest?cb=20220801091438", + "https://static.wikia.nocookie.net/witchers/images/d/d9/New_Jeans_Cover.png/revision/latest?cb=20220801091438", + listOf( + SocialMedia( + SocialMediaType.INSTAGRAM, + "공식 인스타그램", + "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Instagram_logo_2016.svg/264px-Instagram_logo_2016.svg.png", + "https://www.instagram.com/newjeans_official/", + ), + SocialMedia( + SocialMediaType.INSTAGRAM, + "공식 엑스", + "https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/X_logo_2023.svg/531px-X_logo_2023.svg.png", + "https://twitter.com/NewJeans_ADOR", + ), + ), + ), + ) + + override suspend fun loadArtistFestivals( + id: Long, + size: Int?, + lastFestivalId: Long?, + lastStartDate: LocalDate?, + isPast: Boolean?, + ): Result = + Result.success( + FestivalsPage( + isLastPage = false, + festivals = (0..10).flatMap { + listOf( + Festival( + 1, + "예시 페스티벌 1", + LocalDate.parse("2024-05-01"), + LocalDate.parse("2024-05-03"), + "https://source.unsplash.com/random/300×${300}", + School( + 1, + "예시 학교", + "https://source.unsplash.com/random/300×${300 + index++}", + ), + listOf( + Artist( + 101, + "뉴진스뉴진스", + "https://static.wikia.nocookie.net/witchers/images/d/d9/New_Jeans_Cover.png/revision/latest?cb=20220801091438", + ), + Artist( + 102, + "아티스트 B", + "https://source.unsplash.com/random/300×${300 + index++}", + ), + ), + ), + Festival( + 2, + "예시 페스티벌 2", + LocalDate.parse("2024-06-10"), + LocalDate.parse("2024-06-12"), + "https://source.unsplash.com/random/300×${300 + index++}", + School( + 1, + "예시 학교", + "https://source.unsplash.com/random/300×${300 + index++}", + ), + listOf( + Artist( + 101, + "뉴진스뉴진스뉴진스뉴진스뉴진스", + "https://static.wikia.nocookie.net/witchers/images/d/d9/New_Jeans_Cover.png/revision/latest?cb=20220801091438", + ), + Artist( + 102, + "아티스트 B", + "https://source.unsplash.com/random/300×${300 + index++}", + ), + ), + ), + ) + }, + ), + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeBookmarkRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeBookmarkRepository.kt new file mode 100644 index 000000000..9a09cd792 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeBookmarkRepository.kt @@ -0,0 +1,119 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.bookmark.ArtistBookmark +import com.festago.festago.domain.model.bookmark.ArtistBookmarkInfo +import com.festago.festago.domain.model.bookmark.BookmarkType +import com.festago.festago.domain.model.bookmark.FestivalBookmark +import com.festago.festago.domain.model.bookmark.FestivalBookmarkOrder +import com.festago.festago.domain.model.bookmark.SchoolBookmark +import com.festago.festago.domain.model.bookmark.SchoolBookmarkInfo +import com.festago.festago.domain.repository.BookmarkRepository +import kotlinx.coroutines.delay +import java.time.LocalDateTime +import javax.inject.Inject + +class FakeBookmarkRepository @Inject constructor() : BookmarkRepository { + override suspend fun addFestivalBookmark(festivalId: Long): Result { + TODO("Not yet implemented") + } + + override suspend fun getFestivalBookmarks( + festivalIds: List, + festivalBookmarkOrder: FestivalBookmarkOrder, + ): Result> { + delay(1000) + return Result.success( + listOf( + FestivalBookmark( + festival = FakeFestivals.plannedFestivals[0], + createdAt = LocalDateTime.now(), + ), + FestivalBookmark( + festival = FakeFestivals.plannedFestivals[1], + createdAt = LocalDateTime.now(), + ), + FestivalBookmark( + festival = FakeFestivals.plannedFestivals[2], + createdAt = LocalDateTime.now(), + ), + ), + ) + } + + override suspend fun getFestivalBookmarkIds(): Result> { + return Result.success(listOf(1, 2, 3)) + } + + override suspend fun deleteFestivalBookmark(festivalId: Long): Result { + TODO("Not yet implemented") + } + + override suspend fun addSchoolBookmark(schoolId: Long): Result { + TODO("Not yet implemented") + } + + override suspend fun getSchoolBookmarks(): Result> { + delay(1000) + return Result.success( + listOf( + SchoolBookmark( + school = SchoolBookmarkInfo( + id = 1, + name = "School 1", + logoUrl = "https://picsum.photos/200/300", + ), + createdAt = LocalDateTime.now(), + ), + SchoolBookmark( + school = SchoolBookmarkInfo( + id = 2, + name = "School 2", + logoUrl = "https://picsum.photos/200/300", + ), + createdAt = LocalDateTime.now(), + ), + SchoolBookmark( + school = SchoolBookmarkInfo( + id = 3, + name = "School 3", + logoUrl = "https://picsum.photos/200/300", + ), + createdAt = LocalDateTime.now(), + ), + ), + ) + } + + override suspend fun deleteSchoolBookmark(schoolId: Long): Result { + TODO("Not yet implemented") + } + + override suspend fun addArtistBookmark(artistId: Long): Result { + TODO("Not yet implemented") + } + + override suspend fun getArtistBookmarks(): Result> { + delay(1000) +// return Result.failure(Exception("Failed to get artist bookmarks")) + return Result.success( + (0..20).map { + ArtistBookmark( + ArtistBookmarkInfo( + id = it.toLong(), + name = "Artist $it", + profileImageUrl = "https://picsum.photos/200/30$it", + ), + LocalDateTime.now(), + ) + }, + ) + } + + override suspend fun deleteArtistBookmark(artistId: Long): Result { + TODO("Not yet implemented") + } + + override fun isBookmarked(id: Long, type: BookmarkType): Boolean { + return false + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivalRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivalRepository.kt new file mode 100644 index 000000000..6f01a5954 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivalRepository.kt @@ -0,0 +1,110 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.artist.Artist +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.model.festival.FestivalDetail +import com.festago.festago.domain.model.festival.FestivalFilter +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.model.festival.PopularFestivals +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.domain.model.school.School +import com.festago.festago.domain.repository.FestivalRepository +import java.time.LocalDate +import javax.inject.Inject + +class FakeFestivalRepository @Inject constructor() : FestivalRepository { + + override suspend fun loadPopularFestivals(): Result { + return Result.success( + PopularFestivals( + title = "인기 축제 목록", + festivals = FakeFestivals.popularFestivals, + ), + ) + } + + override suspend fun loadFestivals( + schoolRegion: SchoolRegion?, + festivalFilter: FestivalFilter?, + lastFestivalId: Long?, + lastStartDate: LocalDate?, + size: Int?, + ): Result { + val notNullSize = size ?: DEFAULT_SIZE + val notNullLastFestivalId = lastFestivalId ?: DEFAULT_LAST_FESTIVAL_ID + + if (festivalFilter == FestivalFilter.PLANNED) { + return Result.success( + FestivalsPage(isLastPage = true, festivals = FakeFestivals.plannedFestivals), + ) + } + + if (notNullLastFestivalId + notNullSize < LAST_ITEM_ID) { + return Result.success( + FestivalsPage( + isLastPage = false, + festivals = getFestivals((notNullLastFestivalId + 1)..(notNullLastFestivalId + notNullSize)), + ), + ) + } + return Result.success( + FestivalsPage( + isLastPage = true, + festivals = getFestivals((notNullLastFestivalId + 1)..LAST_ITEM_ID), + ), + ) + } + + private fun getFestivals(idRange: LongRange): List { + return (idRange).map { id -> + Festival( + id = id, + name = "뉴진스 콘서트 $id", + startDate = LocalDate.MIN, + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "고려대", imageUrl = ""), + artists = listOf( + Artist( + id = 6L, + name = "뉴진스", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 1L, + name = "BTS", + imageUrl = "https://i.namu.wiki/i/gpgJvt_C2vKJS4VA4K_Vm57Y5WoS83ofshxhJlQaT4P9Tu0N96vZ2OcdeAN7ZtRAM26UyyQs3sualkKk6i_SrRMvwVKrU015XJqzJ7wKRbOub_oUAxPSFre_8D5De3oy-fCxL0uZ-HGvsWxIX57yrw.webp", + ), + Artist( + id = 2L, + name = "싸이", + imageUrl = "https://i.namu.wiki/i/VH58lI8f-y8QSoxFH9IAjjCobySN0lflZ4rMy6Un7qawUwAyi9UfeseZWCzxH-lQeZk7q_eUyTHGlZBAPqSLWliIKWYDLaAgomVtOyAQg60aCpF3oNTBOgUe_hig3rbHW-YAgoj95Fww3MCToyM6MA.webp", + ), + Artist( + id = 10L, + name = "마마무", + imageUrl = "https://i.namu.wiki/i/Mre8tXnE40mB9_UwXIwASMEAUSVhHvyjJxXq-lQo40C3bLWYfxXBeai8t6TugyomPjFgxL3VfDA2zn65HlzqPXgTKlvdRl1gJ6PGZLxYYk8Uhk8L6va7zm_etSK5UzVLE56fUATqUCq-6tRQXigmYQ.webp", + ), + Artist( + id = 11L, + name = "블랙핑크", + imageUrl = "https://i.namu.wiki/i/VZxRYO8_CXa2QbOSZgttDq5ue5QEu_Fbk1Lwo3qpasLAfS802YExcnmVmDhCq3ONF0ExzhACz_YkZbxOGmIfjuPDZnFo7i0pWaT05NluHRHGfp9NqsAT6WBNb0k5KecOyDvakXk0VH2fUo4ojSwC6g.webp", + ), + ), + ) + } + } + + override suspend fun loadFestivalDetail( + id: Long, + delayTimeMillis: Long, + ): Result { + return Result.success(FakeFestivals.festivalDetail) + } + + companion object { + private const val LAST_ITEM_ID = 27L + private const val DEFAULT_SIZE = 10 + private const val DEFAULT_LAST_FESTIVAL_ID = -1L + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivals.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivals.kt new file mode 100644 index 000000000..02d73c3a3 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeFestivals.kt @@ -0,0 +1,523 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.artist.Artist +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.model.festival.FestivalDetail +import com.festago.festago.domain.model.school.School +import com.festago.festago.domain.model.social.SocialMedia +import com.festago.festago.domain.model.social.SocialMediaType +import com.festago.festago.domain.model.stage.Stage +import java.time.LocalDate +import java.time.LocalDateTime + +object FakeFestivals { + + val festivalDetail = FestivalDetail( + id = 1L, + name = "부경대 대동제", + startDate = LocalDate.now().plusDays(7L), + endDate = LocalDate.now().plusDays(10L), + posterImageUrl = "https://mblogthumb-phinf.pstatic.net/MjAyMzA1MjNfMTMx/MDAxNjg0ODIwNzY5NzQ5.MuYItN1HCOQUcADB6B7ua0SO9Au_QNNk01-6yZkcTH0g.wxSjluY-Glq20JIojs7OuScLQWh6c_sQsoW5xXqiM7Ag.JPEG.chummilmil99/SE-126908ba-0f82-4903-91c5-695db78a98e9.jpg?type=w800", + school = School(id = 2L, name = "부경대학교", imageUrl = ""), + socialMedias = listOf( + SocialMedia( + type = SocialMediaType.INSTAGRAM, + name = "총학생회 인스타그램", + logoUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Instagram_logo_2016.svg/2048px-Instagram_logo_2016.svg.png", + url = "https://www.instagram.com/25th_solution/", + ), + SocialMedia( + type = SocialMediaType.FACEBOOK, + name = "총학생회 페이스북", + logoUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Facebook_f_logo_%282019%29.svg/1200px-Facebook_f_logo_%282019%29.svg.png", + url = "https://www.facebook.com/23rdemotion/", + ), + ), + stages = listOf( + Stage( + id = 1L, + startDateTime = LocalDateTime.now().plusDays(0L), + artists = listOf( + Artist( + id = 1L, + name = "뉴진스뉴진스", + imageUrl = "https://i.namu.wiki/i/gpgJvt_C2vKJS4VA4K_Vm57Y5WoS83ofshxhJlQaT4P9Tu0N96vZ2OcdeAN7ZtRAM26UyyQs3sualkKk6i_SrRMvwVKrU015XJqzJ7wKRbOub_oUAxPSFre_8D5De3oy-fCxL0uZ-HGvsWxIX57yrw.webp", + ), + Artist( + id = 2L, + name = "싸이", + imageUrl = "https://i.namu.wiki/i/VH58lI8f-y8QSoxFH9IAjjCobySN0lflZ4rMy6Un7qawUwAyi9UfeseZWCzxH-lQeZk7q_eUyTHGlZBAPqSLWliIKWYDLaAgomVtOyAQg60aCpF3oNTBOgUe_hig3rbHW-YAgoj95Fww3MCToyM6MA.webp", + ), + Artist( + id = 10L, + name = "마마무", + imageUrl = "https://i.namu.wiki/i/Mre8tXnE40mB9_UwXIwASMEAUSVhHvyjJxXq-lQo40C3bLWYfxXBeai8t6TugyomPjFgxL3VfDA2zn65HlzqPXgTKlvdRl1gJ6PGZLxYYk8Uhk8L6va7zm_etSK5UzVLE56fUATqUCq-6tRQXigmYQ.webp", + ), + Artist( + id = 11L, + name = "블랙핑크", + imageUrl = "https://i.namu.wiki/i/VZxRYO8_CXa2QbOSZgttDq5ue5QEu_Fbk1Lwo3qpasLAfS802YExcnmVmDhCq3ONF0ExzhACz_YkZbxOGmIfjuPDZnFo7i0pWaT05NluHRHGfp9NqsAT6WBNb0k5KecOyDvakXk0VH2fUo4ojSwC6g.webp", + ), + Artist( + id = 4L, + name = "AKMU", + imageUrl = "https://i.namu.wiki/i/7yRF8Yrk9kdQxzETNO8TQp9jJpQENVUGbj-4YwB-xdVmJWoTAY7MgVA6G72Z-xmunPG0Zd3WTN_EsTwsx7oNFIO-yl0nHmaIU-ZRCpyhzVE5L9y8Sb9gkAKVt_jZBtgvVrOjw1UQq32gQsYaoS1jsg.webp", + ), + ), + ), + Stage( + id = 2L, + startDateTime = LocalDateTime.now().plusDays(1L), + artists = listOf( + Artist( + id = 3L, + name = "뉴진스뉴진스", + imageUrl = "https://i.namu.wiki/i/-GuxB5nI9Q-a5W_nAJEapwdUzCLyFShWJfmUfZk04cW_fFC485TRD6UlzGQCBnFpJegXBaa4WO-PThNom_7wlosOiXgb-k3-wgUr3PkyX89PU3RCschmgQ0FmS1ClOK3ph4ztAd55YWWlhk7Gm114w.webp", + ), + Artist( + id = 5L, + name = "뉴진스", + imageUrl = "https://i.namu.wiki/i/GdMUzQlsrAXyF5zlgqRR0lYvAGnFghBbLxqTZK_mzLvV0LYPNQdaak1ezYtKqSNBA7UaINkrMNqncRkxThI8j2IEk2qcXJ3bLqIllRexenai641g-uvxCxFcDa9doCy0kTnMLEp5gad8Ze2fLDDBvg.webp", + ), + Artist( + id = 6L, + name = "비비", + imageUrl = "https://i.namu.wiki/i/JlXBTAah7fOILgmvAQf5bW4yWbS082qw6XtV36g4a-2g5TrwTRaUf95r1YnEYi6dt_rf3o9YuRN2qVl0pdgIW5d6-DeYg67KwaSrqu3_MkUwQItlsrSLqDjm1G0jW-Z5mzQ2aOTU4ZvyE1hpSokIOA.webp", + ), + ), + ), + Stage( + id = 3L, + startDateTime = LocalDateTime.now().plusDays(2L), + artists = listOf( + Artist( + id = 7L, + name = "TWS", + imageUrl = "https://i.namu.wiki/i/rVwKhMepUc-b-hRa2Nc6mIJRO0eTfgxyAEwVS5XfADNRhQhYJdSg8ke3o6VZd3rLyNasMlGjuXJWqHDoD_Z24o3dBzkaf7gqhCc89XoCKOiII4P-eilx46XHOOTfd2eaonCVNQevsAVl0l5WIWaI5Q.webp", + ), + Artist( + id = 8L, + name = "소녀시대", + imageUrl = "https://i.namu.wiki/i/snftu-N6Op26hU4HITlraWW6Q_WiSXqhRX2NOhQadzI81RPC7054_mi-evsqRTdRe9nKcBEF-Ugji4vtWunmtiEY1v319tHhIVestCkcSJ0MZF6KbKOScoDjOypW7WPa58goYA-vX5D8baIa2UYFZg.webp", + ), + Artist( + id = 9L, + name = "르세라핌", + imageUrl = "https://i.namu.wiki/i/Zbm1DseL0fjSd9H2uLrfL9SpBLPYQe7j4S9BPI2wdTw9G_Gykifyw-Nil8yVZglxxW-CRQt15b-tMdrvfuUiSW9mm2ZEBf8sQQQgp9wZmZhe8neg_5A6ehJ6hYLATAqvnOw157aODDq4qU1J-kv-bA.webp", + ), + ), + ), + Stage( + id = 4L, + startDateTime = LocalDateTime.now().plusDays(3L), + artists = listOf( + Artist( + id = 7L, + name = "TWS", + imageUrl = "https://i.namu.wiki/i/rVwKhMepUc-b-hRa2Nc6mIJRO0eTfgxyAEwVS5XfADNRhQhYJdSg8ke3o6VZd3rLyNasMlGjuXJWqHDoD_Z24o3dBzkaf7gqhCc89XoCKOiII4P-eilx46XHOOTfd2eaonCVNQevsAVl0l5WIWaI5Q.webp", + ), + Artist( + id = 8L, + name = "소녀시대", + imageUrl = "https://i.namu.wiki/i/snftu-N6Op26hU4HITlraWW6Q_WiSXqhRX2NOhQadzI81RPC7054_mi-evsqRTdRe9nKcBEF-Ugji4vtWunmtiEY1v319tHhIVestCkcSJ0MZF6KbKOScoDjOypW7WPa58goYA-vX5D8baIa2UYFZg.webp", + ), + Artist( + id = 9L, + name = "르세라핌", + imageUrl = "https://i.namu.wiki/i/Zbm1DseL0fjSd9H2uLrfL9SpBLPYQe7j4S9BPI2wdTw9G_Gykifyw-Nil8yVZglxxW-CRQt15b-tMdrvfuUiSW9mm2ZEBf8sQQQgp9wZmZhe8neg_5A6ehJ6hYLATAqvnOw157aODDq4qU1J-kv-bA.webp", + ), + ), + ), + Stage( + id = 5L, + startDateTime = LocalDateTime.now().plusDays(4L), + artists = listOf( + Artist( + id = 7L, + name = "TWS", + imageUrl = "https://i.namu.wiki/i/rVwKhMepUc-b-hRa2Nc6mIJRO0eTfgxyAEwVS5XfADNRhQhYJdSg8ke3o6VZd3rLyNasMlGjuXJWqHDoD_Z24o3dBzkaf7gqhCc89XoCKOiII4P-eilx46XHOOTfd2eaonCVNQevsAVl0l5WIWaI5Q.webp", + ), + Artist( + id = 8L, + name = "소녀시대", + imageUrl = "https://i.namu.wiki/i/snftu-N6Op26hU4HITlraWW6Q_WiSXqhRX2NOhQadzI81RPC7054_mi-evsqRTdRe9nKcBEF-Ugji4vtWunmtiEY1v319tHhIVestCkcSJ0MZF6KbKOScoDjOypW7WPa58goYA-vX5D8baIa2UYFZg.webp", + ), + Artist( + id = 9L, + name = "르세라핌", + imageUrl = "https://i.namu.wiki/i/Zbm1DseL0fjSd9H2uLrfL9SpBLPYQe7j4S9BPI2wdTw9G_Gykifyw-Nil8yVZglxxW-CRQt15b-tMdrvfuUiSW9mm2ZEBf8sQQQgp9wZmZhe8neg_5A6ehJ6hYLATAqvnOw157aODDq4qU1J-kv-bA.webp", + ), + ), + ), + ), + ) + + val progressFestivals = listOf( + Festival( + id = 1, + name = "뉴진스 콘서트", + startDate = LocalDate.MIN, + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "고려대", imageUrl = ""), + artists = listOf( + Artist( + id = 6L, + name = "뉴진스뉴진스", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 2, + name = "아이브 콘서트", + startDate = LocalDate.MIN, + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "연세대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "뉴진스뉴진스", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 2L, + name = "르세라핌", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 3L, + name = "스트레이키즈", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 4L, + name = "볼빨간사춘기", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 5L, + name = "다이나믹 듀오", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 3, + name = "아이들 콘서트", + startDate = LocalDate.MIN, + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "연세대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "아이들", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 4, + name = "뉴진스 콘서트", + startDate = LocalDate.MIN, + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "부경대", imageUrl = ""), + artists = listOf( + Artist( + id = 6L, + name = "뉴진스", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 5, + name = "아이브 콘서트", + startDate = LocalDate.MIN, + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "연세대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "아이브", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 2L, + name = "르세라핌", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 3L, + name = "스트레이키즈", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 4L, + name = "볼빨간사춘기", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 5L, + name = "다이나믹 듀오", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + ) + + val plannedFestivals = listOf( + Festival( + id = 30, + name = "뉴진스 콘서트", + startDate = LocalDate.now().plusDays(1L), + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "고려대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "뉴진스뉴진스", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 31, + name = "아이브 콘서트", + startDate = LocalDate.now().plusDays(3L), + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "연세대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "아이브 아아아아아아아아아아아아아", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 2L, + name = "르세라핌 르르르르르르르르르르르르르", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 3L, + name = "스트레이키즈", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 4L, + name = "볼빨간사춘기", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 5L, + name = "다이나믹 듀오", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 32, + name = "아이들 콘서트", + startDate = LocalDate.now().plusDays(5L), + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "연세대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "아이들", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 33, + name = "뉴진스 콘서트", + startDate = LocalDate.now().plusDays(20L), + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "부경대", imageUrl = ""), + artists = listOf( + Artist( + id = 6L, + name = "뉴진스", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 34, + name = "아이브 콘서트", + startDate = LocalDate.now().plusDays(40L), + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoHCBQUEhgSFBQYGBgYGBsaGBoYGBgaGhoaHBsZGR0YGhkcIi0kGx0pIBsZJTclKS4wNDQ0GyM5PzkyPi0yNDABCwsLEA8QHhISHjIpJCk2NDgyMjIyMjIyMjIyMjUyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMv/AABEIALcBEwMBIgACEQEDEQH/xAAcAAAABwEBAAAAAAAAAAAAAAAAAQIDBQYHBAj/xABHEAACAQIDBAcFBAgEAwkAAAABAhEAAwQSIQUGMUETIlFhcYGRBzKhscEUQlLRI2JygpKywvAVJHPhM2PxFiU0Q2Sis8PS/8QAGQEAAwEBAQAAAAAAAAAAAAAAAAECAwQF/8QAJhEAAgICAgEEAgMBAAAAAAAAAAECEQMhEjFBBCIyUWGBE3HwI//aAAwDAQACEQMRAD8AuKrTgFEopVdjMA6UKKjqQBR0Qo6QAo6KjoKBRlo40kzGlNISTDDwP+1NIB4Ak68Oz61UN78Firl9WsLcKC2AcjEDNmfkCNYire7gCeyqPvZti6mIVbVx0Xo1JAMCcz6+kV0+lUnP21+xrst+xkdcNaW5IcW0D5jJzZRMnmZqr74Y67bxCqlxkHRqYViBOZ9YHPQVaNkXGbD2mYksbaFieJJUEk1Ud9BOKH+mv8z1Xp1eZ3+Qj2WNrz/4d0mZs/2bNmk5s2Sc09s1E7oY249xg7u4CEwzE65l11rtvXwNnZIOuGA5fgFRe5hi637B/mWrUV/HN15KXxZPbx4hkRArFSzEypIMAd3iKr+wcZdGMRLlx2Rw6wzsROUsDBPHq/GpDeS7NxV/CvzP+1cps9HiUMQUdD5ELPwJp44pYqa20yER++WLvW8WypduKrIjAK7ADTKYAPatW/GYuMGboME21IPewAB9TVX3/s/prb/iQr/C0/112bQv/wDdNvX3ltp/Cw//AAaJQUoY3+aGmP7IxNy4LrF3OW00SxgMeB8dDVNwuPxtxslu7edomA7Tpx51c92BOHu3PxLHopJ+dQ27FnJjx2FHI9BWkOMXPS0VkVu0DZNvaIv2+k6fJnXPmZiuWRM68Kv1c7YqDBGvIDUx2nspKXbk6rp3RPxNcOWTm7aSJo6qQaJbk9x7DxpVYAFRGjoGmITRGlURpkiTSTSjRUwEGkOKcNJYUwGMtCnMtCnYDwo6FHUgGKOiFHSAMUKFHSAFGKKjoAVSHHPs/s0ukXOB8DQUKIrPd80/zY/01j+J60OqHvev+b/cX5tXX6J/9P0Cey47GEYaz/pJ/ItVPfFZxQ/01/merfssfoLQ/wCWn8gqrb1JOJ/cX5tT9M/+z/Y49ke2z8SLec5+jyg+/wBXLGnVzcI5VI7opFx/2D/MtT18f5KP+Sv8oqJ3WWLj/sf1LWryuWOVlp2mN7U6+JK96p8h85p3b4y4gkc1U/T6U1hevige25m8pLV2byp+kRu1I9CfzpXUlH8EVsa30sdJYt3Byb4Ms/QVCYq7OzbNvmLr/DOf6xVrZBcwag/hX4HLVR2haKKts8Fd2/iCD+k1Xp5WlF+GDjqyy7sWcuB/a6Q/Nf6arl3BXHIFrNnExlbKY56yKuODt5MIqdlrXxKyfiajtgENcDcwCD6cazjkacpL7LjuLTI/d/ZuLXEBrwuBMrTmfMJjTTMatqJHOfSfhT0UVc2XK8kraX6M1obZabS518vaJHrBp4010fXDdgP9/Os0Oxw0mnKRUiCojR0DTEJNJNLNJNUISaSaUaSaACihQoUwHKMUKMUgDoxRUoUgCo6jdsbdw+EUNfuZZ4AAsx8FGtR1rfTBNbNw3Ig+4R1yCYDBeemsDXz0pFKL+iyClU1YvLcRbiMGVgCrKZBB5ginaBApvEtC05VA3z39+z3ThrNoOyf8R2JyqSJygDiQCJM91K0nbGuy/wBVXb2x7t2+bltAVyqJzKNRM8TUNsL2lLcvJZxFtEDwBcRmIVjAAdSNATznTnzI0G2ePjWmPI4vlEbQnB2yttFPFUUHxCgGoLbey7ly9nRJGVRMqNRPae+rHVX9oe2mwuDbozD3D0aEcQIl27urpPIsKI5HGXJBHsLam8mFtWega4WfILZCKWAeACpYaEgiIBNN7pYlLhuPbYNClSODBpESp1A048ONZJgbVxwgQ5mZuE6zp8hFSuy9o3MFjFuEzDQ4BBzITDAj4jvApRzNJr7NFFpGn4DZ1y3cDumgmdQeIjke+uzamEe4FCiSszqBoYg6+FSeYMsgyCAQe0HUH0pFnj3jTyP5Vo8rbvyie9nPhMK4w5tsIaGAEjnJGo76j8Vsl7lsBlhxpxGoqY2hjUsWnvXDCIpY9unIDmTwHeaxza2+mMvPmW41tSYFu3IAHKXHWdu/4CpjkknYk2bNft/o2RfwkD0gVWtnbNxFtgTb04HrLw9apWxd+buFuKt641xCYdWJZlB+8jGTI7Jj51rdi8rotxGDKyhlI4EESCPKnHK4JpU7EpNEKdm3BdRxOUOpPW5Ag8JqeoUKicm6sJS5DGJbQDmTp5a0dpTzoXbyLqzKo/WIHzpaMCAQQQeBBkHwNTeqDwHSaVTWIu5BMSeQpWCViqBrgt4xjOdY7IPLvFKwuODsUJE8o5+XGaUZpjljktnWaSaUaI1ZmJNJpRpJpgFQoUKAHBShSRSqQB0CQBJ0A4mgKh97L5t4O4wMaAT3EwfhpSbo0xx5SUfsyrejb32nGsoAZA+VdJYxpp8vXtqz7P8AZ+zWukuPkd1IyQTlnUEsTx4fGuHcrZlu9iemCBQltVQyG/SgKGdv1tQfOtDs2byW2BuK7z1YlRzgHMWg9/wrl5Nnc7jp/wCRVfZ1ce1exOBuHrJlcCNOOViPGUNX+qRsZH/xhnuBVb7IVYBg3W6RSJIAnqjsFXet4NtbOPMqkMY7FLatPdb3URnPgoLH5VgyNdxVxmWWuXHJZV/ExLH4k1uW2rJuYa7bCl86MmUcTnGXy48az7cTd/EWLnSm0BlOQ5+qy6SzZCsniBxGnxU51pDx4+Wyu7c3au4VFa8JVhqdIVjPVJHAxWubr4g3MHZuMZZ0Qse05RJqG33a4+HvW+ilOjYl9IBUFp48o7OYqT3L02fhxMxbUadwiiErux5YcUmTlZ17WEDNhbZaAelkSByTKTPAZonumtFrDvaJjHvY64NSts9Gg7AupgdpYn4U5dGUOy9bB3VwyW7V6E1QFic2aW45XzAKNezlVc3x3dt2QMXbZQGbqrLsWnMT1mYjtOgHLxqy7DR7eDtpe6U9RSr2hcZhIEoyp1jBniI4VQt9NsPcvrhsrqlrX9IZdmInO3ZodF9ewYrs7JNcTXNgXQ2Cwz8jZtz3dRfkakCp0I4jjVN9lm0WuYV7D6i02Ve5WEgeHHyirmog11J6OO6dFc9oub/D3ABMvbzR+HOs/GKp42b0mGD27JzKYEgwTEFZUHUMpHl31qeLw63LbW2Ehh8eIPiCAfKooY3o7nRZG4CAqrqeESWAjQHhwNZTN8O7RlGP3SvtctA2jba64VpgqJEloHMCSQJBjkeqNL9n+ZcI2HZsxw965ZzDmFIYemaI5RXLvpibiWi9mOktguCQGyhecHSeykey0j7G/WLOb7s5bjmZbZnvkQZ7ZpY7bYsqSSLpUDvjt4YLCNdEF2IS2DwLmdSOYABPlHOp4ms99rGGa4uEtggB7zKZ/E2RV+bVb6MYq2VnAbKv40NfGa51GLu5km5BhR3A6wNBFde523bmFvdHcJ6MtldT90zGeDwI59onuq+bES5bwot27Qt5BlRTAJ7zq2pPMk1nG+NprOJJvKFN5Q/VOYA+6ROUcwCTA96so97OqUdM2auHaN1VgsQBrxrm3UxbXcFZuNq2TKx7ShKSfHLPnTu3cGtyyQ06a9UwfI8jpWklaOaGpbOLEbRVAsIz5/dygmfCPrpXM+AK31ug5QCGIMz2EDWOcU7gscqZLVpGZGEoVkzwBDMYCwZnUnXhSdquWuLH3HQuB3mI+vlWF0drjosE0RrmwV2Rl5j5cK6jXVGVqzglHi6EGiNKNJNUSJihR0KYCxShRBqUGpACqL7RdrLlGFUyT1njloYB+fpXZtP2g4azce0EuOyOUYqEC5hxAJaTBkcOVZtt7bIv3muqpUMTGYydf+tROLa0dHppRhLlLx0Se4nSLiyUXNKHMO3KRpPAHX1FXTa227Vhema5cldeiKdaToBmZZVZ5zFQ24+2Vaw6W7NlcVb+/kUPct9oAAlhwPD7p1JNT+0sIb9r7NjgzsMrzZ6s5pgfhMRxbs865pRcWdSyKW0is+z/ABdy/tJ7znVkdnHd1VUDuA0rVKyjBH/CsYCQz23TqORBdDl9LimAR2jlIrUsDfF62ty2ZRhKnkfDz0rbG1Rh6mDtSXVCMapa2ygkEj7pyniOY1FVrdi1iLVhrd4NnZuqbjkk/qSSTpHLTWrrYWAZrk2hg7BBuXLaSqMWdkUkIOs2vZpPlU5I29E4siiqZQd/tv8ARYU4WQb1wHMFObKhOrMeU8APyq07pWDbwOHQ8RaSfEiT8TUXvFulaxtm3cwrojBcyOo6jq/WhgPnxFT+xcJct2Ldp1WURVOVpBIABIkDTSqgqWycsuXQ9j8ULVl7xVmW2jOwWJIUSYkgcBWN7vImN210rt0aNce8qkyWyHOEkc4EnuU1tz2RcttbYdV1ZWHcwKn5153tLct3wtsk3EuZEy8S4bKIHedI760UUzNOjbtpbZXDuAbDsjkBGQZlZiJiB7v1186h7QdiWblhscVFi8HEGWPSqFhVK8Ec6DgfcA4HTRMNhMttFfLmGUsB7gbScoMkCeHHlUXvzgen2diLY1ITOsccyEOAPHLHnWXBmryL6M49le2eixRwzgEYgwH4FXRWIHYQdR4xWuuIPhoawHdLBvex1m0hysHVs3YLZzk+MLW6bWw6XSthxKXG64BKyiqXIJUgwWCgjmDHOqgyJJB4faVq4X6O4r5DDlDmVTxylhpPdMimcdZa4Ue1dyH78Kr5khiMvY0ka6iJ0NcOI2dbt2mt2ycPYthnY22KQfeLFxqAOMcT4aGD3V2muNzoi3YTKzB8g97PqCnaQSZHHh2VMnJ3S0axjGNNvZaLmxke21os3XEM4Yhz2nN/YprDLg8HhjetuosDIpdSbg4i2CWWZMnU9+tN7NxOEdMQj3VdLbFLyXGXIpA11bTKRoeUqe+sl3mxVkYrEDB3WNi4QzKmZLZbQsMmgZQwkGI1EcJqscWuzPI03p2bhhr6XFD23V0b3WQhlPgRVI9rzAYO02aHGIUp26I8ny0+FUDdzb13CXke25CF16ReKusgGV4ZomDxHbTm++8Yx91biqyIgyojEE6kln00BMKPIa08mkTBW7L9u/vMMTbRulto6iLquSDI+8AGGZTxFVP2iYjp71vkoQwx0zSRLAHloI7fDU2X2b7vquEXFMAzXmJCkA5QpKqR3mCfMVG+1LAvntXVtuUCvnZVYquqQWYCF7Neyud3Z18k40WH2aYrPgujJ61tyD4NDA+Bk1Zse0W2HMiAO2sV3X3huYK41xVDqy5WQmA0aqZgwRJ9TWi7I2y+Mt9N0bopbKC2UqSPeykch3gVq5e3Rgoe4Xgtj3Fc3FuOikyyrBDGQCesCFPeONTD7PC22I7ZJPEn61J4Z7YQKXThr1l/OixGKsBMpuWx3F0BJ5Djqal47L/lfRBJcKvI5aH51KLczKGHA8+yDqPn6VzYHC9JOaNBxGgPYfSacd7gY2+jAUaZs2Y6k/dA0HPU0Y1KtCm43s6TSDS81EXNdJyiKFKzUKYChVG3x346Bnw2HANxdHc+7bJ+6o+8w7eA7zMXkVlG9e5OLOKuXMOnSJdcuIdAysxllYMRzJg9lS3Q0UhrnZr/AH203bOY9g7ateI9neNWw15smZRmNsNL5RJOo6sgcgdfHSqzhoOhHIxHMkjjWcpM2xRi3sVg75tXkugk5GVtCVMAyQGGokSPOvQlhJth7ZV0uICDoHcEFh3ElWOuledV7a3n2eXzd2ZhyT7hZD4I7KoH7uWjjrYnOm+PRz74YfCfYHfEOFAl7JEFxcKyFQT1i3MTEEkxEiT3KxCvs/DMoMG2BHesofiDWA7RxLvcYuzMoZ8oJJCgsSQoPu69lah7INt57T4FjrbBe3+wzddfJjP7/dS4pA5tqmaUNAT6edUz2n4+5bwLi2Oq7pbdv1TJZR4xlJ7DHhcHML3f3pVZ9otjNsu4I1U228+kSfmaaWyCX3cxq38JZvKAM6LKjgrAZWUeBBHlUk7xpzqoey6/OA6Pnbuuv8UP/WatmmfvI+UUNUwHBwqgbP3Rc7Xv4u5K20udJb5Z3dQ8j9VCx15kAcjV/Nc+IukLMhRHvHlzOnrqezhVxsTIjfDb4wWFa4YNw9W0v4n4yY+6BqfTmK6t39uWcZZF60e5lPvI0SVYdvfwI4VkuAweI2zjXzXn6K2WIdh7iEnKqqIGZoE8PdJPCKTa+1bExqm4M1ttGCnqXrYPETwdZkTBB7m1dAaPe3QtfbrONsqqBWc3ECgAnK2V1/DLe8BxnlrMJ7SdqX8NisNcsPlKJdcyJUxlBDDmCNPPiKveBxSXbaXbbZlYBlPaCJHhxrNfbRcGfCqOOW6e+CbYHlofShaY7squ3d+MXjU6G4URCZZbalQxHDMSxJE6xMTFWXc7Phdj4zGIxDPCowElSpySJ0MFyZ4ad1ZsiT5Vqaqf+zA94yeKj/ncD+qIgnsBNJAzL1XjJ146njHzbX50l6F760aa6nsp/gX5O3B2c9xLevWdF0EnrMBIHnWmYTczZuGQPjLgdombj9Gg7AFBE+c1TPZ7huk2jYXkjM58ERiD/Flq1+2F7ZSxZCg3CzPm0lUUZSo7AxYH9ys8hcHWi47MbCZRbsKiISCvRqoRsw0cEaNw468INVNPtd3abu190w9i4EyalbmgPRhZhiw1Zj7oNU3cfbS4bFh7zMbYtXEIYkwMmYBRyJZFUR2io7H7fxN24t1rhDJOXIAiqWOduqoAJJiSeMa1Lg0jWMlZq97YWyb1zojatrcjNktMyNl5NkQgRr2V1bdwa4TZF63h5QWkLISZbV8zEntOZh51QdwsRdubStPqxy3M0cFtlZ8lDZQB3gVrO3cF9owt6wDrctOoPYxHV+MURVETlujza0HU6k8TT+EcK6uORB9K5QDXRg8M9xwltSzHgB9ewdpOgrSyKN/3Ru9JhEf9UJPbk6pPfzqWYRbbvb8q492cMLWCsWwR1baSV4M0asPEyfOu7EsIjnM/P+/KmiX9nKaSaUaSaskKhQoUAOijoqOkBxbZuZMLef8ADZuH0RjXni2SIivQ+17SvYuW3JCujIxWAYZSpieetYvtvdi7hzmWbianMqmVAj3wJy8eMx4UqsadEGK2n2UBhs3UadK5XvGk/wDuDelYsmug1J0AGsnkBXovd3Zow2EtYfmiAMRzc9Zz5sWNKXQ0efdu4XosRetEe5duL5BjB9INObC2xewN9cRb4kcGGjoTBHhK8RwIrY9ubjYTFO911dbjwWdGI1AAnKZXkOVV3eDc23ctJZQhbllQqO0w6iTlcDvJMgaEntpOLl0NSS7LjgN4UxCqqI6s6hgcpK5SJLBxpwPOOVdG9tstgcQAJi0zR+wM/wDTUJu1i7WBwiYbF4q0HScsvl6pJIADQSBJExT+0N+NmZHttiQ2ZGUhEuOIII4hY+NJ6YkQvsoxcnEWuEBHHnnVo9E9av8AbHXJ5AQB58fhWFbpbx/YMQbrozhrRQqrBZJKMDJ5DKfWtD3M33+3Yh7LW1txbzIM5YtlIBEwBwIMR21Uu2NFtu3glwzwZQ08gQY18QR/BVJ383vt2P8ALC10wuI2YZ2QBD1Yletr1uEaDjqate2BDcdWAAHcCdfjWF714zpsZdaZVWyL4J1dPE5j5018ReSQ2XvrfwqNbw1qxaVmLmFdzMAas7sTAAoY3ffF3svTLh7uQyufD22gnmAarQoE0Ds1bcLfG5fa5burbzKqsgRcgImCYGmhy8O2qf7R8XcuY9jcULlRVSJgpqwIn9ZmnvFRGw9onD4hLw4KYcdqnRh6ajvArV958Vh7eCa7etW7pBi0HVWl2nLBPAcz3A030LyYzhiIOorQtsb3YW5slcHaZxcAtqZQoGgguZAygHrad9Z1lkyfy+A4V0i4YAhSB2j48aSsboZZhESJmmu6nHWeXpU/uXsdMRfPSEFUXMUIkPMrBM6QSDzopthaLD7IMFmxV3EEaW0CDxc/MBD6057WSFxllgwlrJBHZDmCe4kn0qz7Lv4HZWHIa5lLEu2Ydd2UKuVFHIaad81ku8G2HxmIfEPpOiL+BQTC/Ek95NTJXoadbI5zy+tLJhIPMz5AfnSMtBhTpis072N4ckYi+QIlLanmIDO48NU9K1BdSB/elYx7Mt5LWFe5ZvuER4ZWM5QwEMCRwkZdf1e+pffH2iWzZbD4NizOCrXAGUIp0IQmCWPCeA5VHSG9sznbEDE3oiOmuRHCM7RHdTC4l1VlUkBhDRoSPwnu7qZFA0/AHpTZuIRrKOhzIygpB0IIEflTYxgZ2UGTMk8ieEDuFYhudtG4mJt2ukbo2LLkLNkllJkLMA5gNa1bZfv+VbRppszlp0T1JNKpJqQCoUJoUwHBRzRCk3eBikBG7VxIPUB8a6thWh0ZfmzR5D/eahxhmLwRzqy4C1ltqvZM+Mk1c9RoUdsYxGw8LcuLcuWLbOrBlfIMwKmQcw1Oo4GpJeNEOFBRrWBYTjWobbVrUOPA/T61L23zKrd0HxGh+NR+3ri28O9x2VVVSZYgCRqBJ5k6RVQdMUkY/vriEOPQMoYKqKwmJElon96mMVZTLaZUAPU1gcGRn8eY4/lVdxWMe5cN12lmbMT2Hjp2Ck9M50zNAiBmOkd1Upq7Hx0W9MPafD3WdVACMQ0CVYCQVPbMDv4VXd39rPhMQmIQSUOo5Mp0ZfME+Bg8qjsxPEnzJohSnPk7ocY8T0TtfEq+EXFWzKZOkB4EqVzDz4V57B0nmdTWqbrbTz7AxFsmTZt3k8ipdfgwjwPZWToeVJPwFDopLNRspoZaYAAqV2nt179ixYbhZUgn8Te6pPggA8SajDTS8SKAHFpa0gUoimQKFWf2f/8Ai3/0X/nt1V0q07gL/mXbn0ZUDtLOn5VS7Bl9x+59rHqr3XuIUkJkZAIMSSGUzqO0cKh73smT7mMYftWg3ycVpGFtZVVewD8/rTris3LdlLoyp/ZRcHu4tD2ZrbL8Qxio3FezLHLqpsv2BXYE/wASAfGtnJpTUuTGeesTu7iMIt18Vhyo6IhCYZczOiAhlJGYBiRryNVwVtXtfcLgVHNrqKPCGcz/AACsWqa2Ug6FCjpiHMHdNu6lwaZXVvRga3fZFvrFvKsCYaV6A2A02lb8QB9RWkHpkz7RKmiozSTTJCoUKFMBwUoUgUoVIHNtLEizZuXoHUR3/hUtHwrz7htqX7dxr1u7cR2JZnVmUkkySY46z61t2/LkbOxJGhyR5FlB+BNYMBUS7LgWux7Q9pJ/54Yfr27Z+IUGnbntJ2kRpdRe8Wrc/EGqeaFIZOPvhtAz/m7okkwrBRLEkwFAA1J4VFYzaF27rdu3LhHDO7PHhmJiuanLVlnkIpbKpZsomFXUsewDtpFCFNGtEFNKVDFCJCoCjUa60ZSmkFls3DxZC42wQSlzB3mOhIVraMVY9ghmE9pFVS0hYhVBZjAAAJJPYAOJqw7mC709xLamLti9YZ4OVc9slczAEDrKvrUrsrdLG4O/bvkIyjjkbXKdCQGA4cdOMUrd0hpeWcFncnaLLmGFaO97an+EvIqDxuHe05S6hRhxVhB/3HeK9D7MxIuWww5io/H4BOnF0opZhlJKgnSSNfM0v5GaLEm6MDVCRIViBzCkj1ArnBGbQ16D20xFgqDBYZRHIt1frWZ7Z9njoyfZrgcOGMPCMCuXTMNGme6mpN7JlBLSKcKNjXTj9nXrDi3etMjHgCJzfssJDeRNdrbr40Ibn2W5l/ZBb+AHN8K05Iz4v6ItBpV69lGGD4t2PBEDeeaB9fSqVftNbOW4jIex1Kn0atE9jluXxL8sttfi5/KqvQqNRt8SaVQQaUaishiGpT0luPnS3FAGde2Uf5O0f/UL/wDHcrHBW4e1mzm2YW/BcRvUlP6qw8UFIMUdCurZ+DN12UaZbdy4fC3bZ48yoHnQI42r0fg8OLdtUH3VA9AK887OAN+0G903EnwzCa9GsaqIpCTSTRmiJqyAUKFCmAsUYpNKFSBG7x7MXFYW5YZmUMAZWJ6pDga8pAqm4P2ZWWUFr9yY5BAPiK0HEe437LfI0MJ7o8KyyG2JJpmX7Y9ntq0pZL76Cesin5RTOM3OtC0ioWDlkDMSTyOaF4cavm3Wkqva3y1+lRl4SVH60+gNOCuLbKnXJRQvYu62EtW1HQo7DUu6hmJPiNPAaUjEYC07vbyKqlChCALo5lgI4cB61NJchJqIw7zmf8TE/QfKlhjci83tjohMX7PbCMlwXbnREgOCVzgkmCGyxl4CInXjVgs+zvZ5T3bh049I0/DT4U7evlrfRngamNgYrPbAPvDqt4jQ1WROOzLHUkZztX2fKuIVLV0qhBJzjOw14LESI7amsNuHgrVtnuB7rATLsVA05KkfGatW1Lf6RG7yPX/pXLtm5+jCfjIXy5/CayTbdHRwildENsqzbtW1tqmUCCIMQec9s8DNdOK2srPkM6aTx9RxFcmcpox6vJuzub8/XvhtoHrvoJ4iQOXCvRxYo2zllJyVMtm7+KKXGtnRW6yeHMevLwqw4xJWRxGoqgbIxwdQR1XRs0T28Y7j2VecNiAyjvFcWeHGTR0Y3yV/RE7Uu5nRBy6x8tB8SKYu3TKE8FcejdX5kelIvD9K58AO7mfnSLuqkd2njyrWEPZ/ZjknWT+iZv4VbhQMoYSGggEAqZB15zGtSpAC1w7L6yh+7Sl7WxQt2ye7Qdp5CuajeT2QO1HW5dggEJqZiJ5fnT26z5WfKAFY69UAtlkZtPE+tQT2HuEAtCSWeOLseU/h5eVT2z7gST3aV2RhxjRyznbJrEbctW56TMoBAkKz8SANFBPE9lOpt3CSAcRaUngHdUb+FoPwqq4s5ww7Rp48j6104DFLds666ag+kEGufKuNGmKCkmWhMVbY6XEPg6n60MXjbdtczuIHMAt/KDVX3bwtu21wW0VAXkhQAJgawPKureFx0eX8RC+pArNTst4knsi97dpWcdgbtjDszu2QqcjqpKurRncAcjWQYnY+Itvka0xYAHqAuIPesitcmudXy30aYzKR6EEfM10ThUbMYe6XEzTY+7eJxTMltIyxmLnIBPDjr8KuWwNycThnuXLvRkHD3UUKxJLOuUcVAiJ9auuA/wCI3gPqfrQ21iiLbBeMQPGufk3o6P40jDk2Tig4C2LpYHTKjNqDyIEHxFehFaQCeYqs7LEXFA5aelWaunhxORysBojQJoiaYgqFJmhQA6KUKbU0oUmAnFNFt/2T8oorDwvlTePP6MjtIHxn6VH38Vlt5Qdawyd0dWCNps49o3M1yOwfP+zXG7dZfP6UM0szTMn5afnRhMxgcSCB8D9K341jM+V5jp2riejt6cSNK58OuVFHYAD4xRY5GKiRw08O004iyYqcCqy/VPpBzXbsV8txo5wfofpTGJw5SJ50Ww7y3C1xZyhmSSIkqYJHdMie41WauJlgvkWDaTjIp/WX4kCoHal9Wuog+6GY/wAv1pjefbgsolskS91BJIAUZ1JJPKADXPbcPcZwQ0gagg/EeVYYlckdeRKMH/uzoImo7E7OmSpjuIlfLmvkY7qkkQmpHAYAOCTXby47OBNormAwGoDLBzSGVp+Oh8oqY2njWtWwymCBp391dSYcJdK90+v/AEqA3vRrkWrZ1eFH7xj5TXLmm5SO/wBPqNsGy8e1y2Lj8XJbTsmB46AV3LeHf6GmtmYYLbt2xrlVV9ABVlxGEXo+GoFbppJI45tOTYrZLhbKxyEVH7b62X8M6/SoNduizihh3HVdQyNyBJIII7NJnvNWQ9cawQRXP1OzprlC0RA0oZqN0gkU9bwTsJiuq0cTVHOTTGAfK7iNCTw7zNP3EIMGndnYUXGY8CDrHMQKw9Srijp9LKpO/oRsG9kvsjH3hm+JX6CunazZuGsMD8ajdr4TLcVlOUgAAjx5jnq3xNdYtObbZtDHLh41y1TTOp1KznJri2isqrTGVgZ8dPmRXUrTTd4aa93zr0Jq4M8+D4zT/I7svaBF50PNVI/hiuvaDdXzHzFFawSrdRgPfSD+4dP567trWAtsnurhito9Cck00R+yhNwVZKrex2/SeVWSu2XZ5iCJpBNGaQxpDBmoU3moU6EOo1OqaFCpYyn7c33w9q+bGR3ZTlaIVQT3nU+lM4PeSxiwwtK63FgEMBwJjMpBI+RoqFYte79nRGTS19HQvdUJvNt25g3ssiqQyuSrTGhWCCDIPH1oqFb5fizCHyK7iN9sY89ZcplsuRIA7AYnT1rRtiMbmRiIJVSR2EgEihQrPF5KyO6ssmIwiuNawXC7wYnD3HNm6yqXY5TDLqSfcaRPfR0KnL4DGc+09sXsUw6VgewAACe3Sp3cjDg4m7c/AuUfvEifRT60KFTi7Rc3ZrOysOCkkcakrNsKIFChW0uzJETiW/zD9yqPhP1qsXsRnxncivcPgq5QPV58qFCsH2ehD4ImdipLIDyA+VWd1lSO6hQrol2eeUHb+yhcJK6XLal0PbkMlT3Gal9gYwPZB7h8RQoVlP5M6sfxR3YCyGuEnkBU0qihQql0YT+TOTGYNWBMa1D7IOW869qg/MflQoUS+LDH8gtvJ1Qe/wD3+lKw7zb17KFCsH0daHdj4NSgc6yW+DEfSubbuHAOmkihQrqh4OKfyf8AY5hHzCyf1X/+uurbf/CoUK5fJ2MPC7PRDmHGu0mhQrqZwiGNNO1ChVIBjNQoUKsR/9k=", + school = School(id = 1L, name = "연세대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "아이브", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 2L, + name = "르세라핌", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 3L, + name = "스트레이키즈", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 4L, + name = "볼빨간사춘기", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 5L, + name = "다이나믹 듀오", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + ) + + val popularFestivals = listOf( + Festival( + id = 1, + name = "뉴진스 콘서트", + startDate = LocalDate.now().minusDays(5L), + endDate = LocalDate.now().minusDays(3L), + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "고려대", imageUrl = ""), + artists = listOf( + Artist( + id = 6L, + name = "뉴진스뉴진스뉴진스뉴진스뉴진스", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 2, + name = "아이브 콘서트", + startDate = LocalDate.now().minusDays(2L), + endDate = LocalDate.now().plusDays(1L), + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "연세대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "아이브", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 2L, + name = "르세라핌", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 3L, + name = "스트레이키즈", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 4L, + name = "볼빨간사춘기", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 5L, + name = "다이나믹 듀오", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 3, + name = "아이들 콘서트", + startDate = LocalDate.now().plusDays(5L), + endDate = LocalDate.now().plusDays(6L), + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "연세대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "아이들", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 4, + name = "뉴진스 콘서트", + startDate = LocalDate.MIN, + endDate = LocalDate.MAX, + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "부경대", imageUrl = ""), + artists = listOf( + Artist( + id = 6L, + name = "뉴진스", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 3L, + name = "스트레이키즈", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 4L, + name = "볼빨간사춘기", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 5L, + name = "다이나믹 듀오", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + Festival( + id = 5, + name = "아이브 콘서트", + startDate = LocalDate.now().plusDays(10L), + endDate = LocalDate.now().plusDays(11L), + imageUrl = "data:image/jpeg;base64,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", + school = School(id = 1L, name = "연세대", imageUrl = ""), + artists = listOf( + Artist( + id = 1L, + name = "아이브", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 2L, + name = "르세라핌", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 3L, + name = "스트레이키즈", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 4L, + name = "볼빨간사춘기", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + Artist( + id = 5L, + name = "다이나믹 듀오", + imageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + ), + ), + ), + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchool.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchool.kt new file mode 100644 index 000000000..f26d51ad3 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchool.kt @@ -0,0 +1,28 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.school.SchoolInfo +import com.festago.festago.domain.model.social.SocialMedia +import com.festago.festago.domain.model.social.SocialMediaType + +object FakeSchool { + val googleSchool = SchoolInfo( + id = 1, + schoolName = "구글대학교", + logoUrl = "https://cdn1.iconfinder.com/data/icons/logos-brands-in-colors/544/Google__G__Logo-512.png", + backgroundUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/1200px-Google_2015_logo.svg.png", + socialMedia = listOf( + SocialMedia( + type = SocialMediaType.INSTAGRAM, + name = "구글대학교 인스타", + logoUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Instagram_logo_2016.svg/2048px-Instagram_logo_2016.svg.png", + url = "https://www.instagram.com/", + ), + SocialMedia( + type = SocialMediaType.INSTAGRAM, + name = "구글대학교 X", + logoUrl = "https://about.x.com/content/dam/about-twitter/x/brand-toolkit/logo-black.png.twimg.1920.png", + url = "https://twitter.com/?lang=en", + ) + ) + ) +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchoolRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchoolRepository.kt new file mode 100644 index 000000000..a1e841a94 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSchoolRepository.kt @@ -0,0 +1,28 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.model.school.SchoolInfo +import com.festago.festago.domain.repository.SchoolRepository +import java.time.LocalDate +import javax.inject.Inject + +class FakeSchoolRepository @Inject constructor() : SchoolRepository { + override suspend fun loadSchoolInfo(schoolId: Long, delayTimeMillis: Long): Result { + return Result.success(FakeSchool.googleSchool) + } + + override suspend fun loadSchoolFestivals( + schoolId: Long, + size: Int?, + isPast: Boolean?, + lastFestivalId: Int?, + lastStartDate: LocalDate?, + ): Result { + return Result.success( + FestivalsPage( + isLastPage = true, + festivals = FakeFestivals.progressFestivals, + ), + ) + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSearchRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSearchRepository.kt new file mode 100644 index 000000000..a337335e9 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeSearchRepository.kt @@ -0,0 +1,94 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.search.ArtistSearch +import com.festago.festago.domain.model.search.FestivalSearch +import com.festago.festago.domain.model.search.SchoolSearch +import com.festago.festago.domain.repository.SearchRepository +import kotlinx.coroutines.delay +import java.time.LocalDate +import javax.inject.Inject + +class FakeSearchRepository @Inject constructor() : SearchRepository { + private var times = 0 + override suspend fun searchFestivals(searchQuery: String): Result> { + delay(1000) + times++ + if (times % 2 == 0) { + return Result.success(listOf()) + } + return Result.success(listOf()) + } + + override suspend fun searchArtists(searchQuery: String): Result> { + delay(1000) + if (times % 5 == 0) { + return Result.failure(Exception()) + } + return Result.success( + listOf( + ArtistSearch( + id = 6L, + name = "뉴진스뉴진스뉴진스뉴진스뉴진스", + profileImageUrl = "https://cdn.mediatoday.co.kr/news/photo/202311/313885_438531_4716.jpg", + todayStage = 2, + upcomingStage = 1, + ), + ArtistSearch( + id = 1L, + name = "BTS", + profileImageUrl = "https://i.namu.wiki/i/gpgJvt_C2vKJS4VA4K_Vm57Y5WoS83ofshxhJlQaT4P9Tu0N96vZ2OcdeAN7ZtRAM26UyyQs3sualkKk6i_SrRMvwVKrU015XJqzJ7wKRbOub_oUAxPSFre_8D5De3oy-fCxL0uZ-HGvsWxIX57yrw.webp", + todayStage = 2, + upcomingStage = 2, + ), + ArtistSearch( + id = 2L, + name = "싸이", + profileImageUrl = "https://i.namu.wiki/i/VH58lI8f-y8QSoxFH9IAjjCobySN0lflZ4rMy6Un7qawUwAyi9UfeseZWCzxH-lQeZk7q_eUyTHGlZBAPqSLWliIKWYDLaAgomVtOyAQg60aCpF3oNTBOgUe_hig3rbHW-YAgoj95Fww3MCToyM6MA.webp", + todayStage = 2, + upcomingStage = 3, + ), + ArtistSearch( + id = 10L, + name = "마마무", + profileImageUrl = "https://i.namu.wiki/i/Mre8tXnE40mB9_UwXIwASMEAUSVhHvyjJxXq-lQo40C3bLWYfxXBeai8t6TugyomPjFgxL3VfDA2zn65HlzqPXgTKlvdRl1gJ6PGZLxYYk8Uhk8L6va7zm_etSK5UzVLE56fUATqUCq-6tRQXigmYQ.webp", + todayStage = 2, + upcomingStage = 4, + ), + ArtistSearch( + id = 11L, + name = "블랙핑크", + profileImageUrl = "https://i.namu.wiki/i/VZxRYO8_CXa2QbOSZgttDq5ue5QEu_Fbk1Lwo3qpasLAfS802YExcnmVmDhCq3ONF0ExzhACz_YkZbxOGmIfjuPDZnFo7i0pWaT05NluHRHGfp9NqsAT6WBNb0k5KecOyDvakXk0VH2fUo4ojSwC6g.webp", + todayStage = 1, + upcomingStage = 5, + ), + ), + ) + } + + override suspend fun searchSchools(searchQuery: String): Result> { + delay(1000) + return Result.success( + listOf( + SchoolSearch( + id = 1L, + name = "부경대학교", + logoUrl = "htts://www.pknu.ac.kr/images/front/sub/univ_logo00.png", + upcomingFestivalStartDate = LocalDate.now().plusDays(10L), + ), + SchoolSearch( + id = 2L, + name = "서울대학교", + logoUrl = "https://blog.kakaocdn.net/dn/CYoCP/btrSeivmaxD/e7JaOZVPI3Je55nAJaHDMK/img.png", + upcomingFestivalStartDate = LocalDate.now().plusDays(3L), + ), + SchoolSearch( + id = 3L, + name = "서울과학기술대학교", + logoUrl = "https://www.seoultech.ac.kr/site/www/images/intro/img_ui01_01.gif", + upcomingFestivalStartDate = null, + ), + + ), + ) + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeUserRepository.kt b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeUserRepository.kt new file mode 100644 index 000000000..3a8b86fcb --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/repository/FakeUserRepository.kt @@ -0,0 +1,48 @@ +package com.festago.festago.data.repository + +import com.festago.festago.domain.model.user.Token +import com.festago.festago.domain.model.user.UserInfo +import com.festago.festago.domain.repository.UserRepository +import java.time.LocalDateTime +import javax.inject.Inject + +class FakeUserRepository @Inject constructor() : UserRepository { + + override suspend fun isSignRejected(): Boolean { + return false + } + + override suspend fun isSigned(): Boolean { + return true + } + + override suspend fun getRefreshToken(): Result { + return Result.success(Token("", LocalDateTime.now())) + } + + override suspend fun getAccessToken(): Result { + return Result.success(Token("", LocalDateTime.now())) + } + + override suspend fun signIn(idToken: String): Result { + return Result.success(Unit) + } + + override suspend fun signOut(): Result { + return Result.success(Unit) + } + + override suspend fun rejectSignIn() { + // handle reject sign in + } + + override suspend fun deleteAccount(): Result { + return Result.success(Unit) + } + + override suspend fun getUserInfo(): Result { + return Result.success(UserInfo("", "")) + } + + override suspend fun clearToken() = Unit +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt b/android/festago/data/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt new file mode 100644 index 000000000..fd362b2f5 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/retrofit/AuthInterceptor.kt @@ -0,0 +1,34 @@ +package com.festago.festago.data.retrofit + +import com.festago.festago.domain.repository.UserRepository +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +class AuthInterceptor(private val userRepository: UserRepository) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + return runBlocking { + chain.proceed(request = getNewRequest(chain)).also { + if (it.code == 401) + userRepository.clearToken() + } + } + } + + private suspend fun getNewRequest(chain: Interceptor.Chain): Request = + chain.request() + .newBuilder() + .addHeader( + HEADER_AUTHORIZATION, + AUTHORIZATION_TOKEN_FORMAT.format( + userRepository.getAccessToken().getOrNull()?.token ?: "TokenIsNull", + ), + ).build() + + companion object { + private const val HEADER_AUTHORIZATION = "Authorization" + private const val AUTHORIZATION_TOKEN_FORMAT = "Bearer %s" + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/service/ArtistRetrofitService.kt b/android/festago/data/src/main/java/com/festago/festago/data/service/ArtistRetrofitService.kt new file mode 100644 index 000000000..d8fc53a16 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/service/ArtistRetrofitService.kt @@ -0,0 +1,26 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.artist.ArtistDetailResponse +import com.festago.festago.data.dto.festival.FestivalsResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query +import java.time.LocalDate + +interface ArtistRetrofitService { + + @GET("api/v1/artists/{artistId}/festivals") + suspend fun getArtistFestivals( + @Path("artistId") artistId: Long, + @Query("size") size: Int?, + @Query("lastFestivalId") lastFestivalId: Long?, + @Query("lastStartDate") lastStartDate: LocalDate?, + @Query("isPast") isPast: Boolean?, + ): Response + + @GET("api/v1/artists/{artistId}") + suspend fun getArtistDetail( + @Path("artistId") artistId: Long, + ): Response +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/service/AuthRetrofitService.kt b/android/festago/data/src/main/java/com/festago/festago/data/service/AuthRetrofitService.kt new file mode 100644 index 000000000..8d0333b86 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/service/AuthRetrofitService.kt @@ -0,0 +1,35 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.user.RefreshRequest +import com.festago.festago.data.dto.user.RefreshResponse +import com.festago.festago.data.dto.user.SignInRequest +import com.festago.festago.data.dto.user.SignInResponse +import com.festago.festago.data.dto.user.SignOutRequest +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.Header +import retrofit2.http.POST + +interface AuthRetrofitService { + @POST("api/v1/auth/login/open-id") + suspend fun signIn( + @Body signInRequest: SignInRequest, + ): Response + + @POST("api/v1/auth/refresh") + suspend fun refresh( + @Body refreshRequest: RefreshRequest, + ): Response + + @POST("api/v1/auth/logout") + suspend fun signOut( + @Header("Authorization") token: String, + @Body signOutRequest: SignOutRequest, + ): Response + + @DELETE("api/v1/auth") + suspend fun deleteAccount( + @Header("Authorization") token: String, + ): Response +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/service/BookmarkRetrofitService.kt b/android/festago/data/src/main/java/com/festago/festago/data/service/BookmarkRetrofitService.kt new file mode 100644 index 000000000..482b71089 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/service/BookmarkRetrofitService.kt @@ -0,0 +1,41 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.bookmark.ArtistBookmarkResponse +import com.festago.festago.data.dto.bookmark.FestivalBookmarkResponse +import com.festago.festago.data.dto.bookmark.SchoolBookmarkResponse +import com.festago.festago.domain.model.bookmark.BookmarkType +import com.festago.festago.domain.model.bookmark.FestivalBookmarkOrder +import retrofit2.Response +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PUT +import retrofit2.http.Query + +interface BookmarkRetrofitService { + @PUT("api/v1/bookmarks") + suspend fun addBookmark( + @Query("resourceId") resourceId: Long, + @Query("bookmarkType") bookmarkType: BookmarkType, + ): Response + + @DELETE("api/v1/bookmarks") + suspend fun deleteBookmark( + @Query("resourceId") resourceId: Long, + @Query("bookmarkType") bookmarkType: BookmarkType, + ): Response + + @GET("api/v1/bookmarks/schools") + suspend fun getSchoolBookmarks(): Response> + + @GET("api/v1/bookmarks/festivals/ids") + suspend fun getFestivalBookmarkIds(): Response> + + @GET("api/v1/bookmarks/festivals") + suspend fun getFestivalBookmarks( + @Query("festivalIds") festivalIds: List, + @Query("festivalBookmarkOrder") festivalBookmarkOrder: FestivalBookmarkOrder, + ): Response> + + @GET("api/v1/bookmarks/artists") + suspend fun getArtistBookmarks(): Response> +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/service/FestivalRetrofitService.kt b/android/festago/data/src/main/java/com/festago/festago/data/service/FestivalRetrofitService.kt new file mode 100644 index 000000000..52881d3d7 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/service/FestivalRetrofitService.kt @@ -0,0 +1,29 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.festival.FestivalDetailResponse +import com.festago.festago.data.dto.festival.FestivalsResponse +import com.festago.festago.data.dto.festival.PopularFestivalsResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query +import java.time.LocalDate + +interface FestivalRetrofitService { + @GET("api/v1/popular/festivals") + suspend fun getPopularFestivals(): Response + + @GET("api/v1/festivals") + suspend fun getFestivals( + @Query("region") region: String?, + @Query("filter") filter: String?, + @Query("lastFestivalId") lastFestivalId: Long?, + @Query("lastStartDate") lastStartDate: LocalDate?, + @Query("size") size: Int?, + ): Response + + @GET("api/v1/festivals/{festivalId}") + suspend fun getFestivalDetail( + @Path("festivalId") festivalId: Long, + ): Response +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt b/android/festago/data/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt new file mode 100644 index 000000000..6115210d6 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/service/SchoolRetrofitService.kt @@ -0,0 +1,25 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.schooldetail.SchoolFestivalsResponse +import com.festago.festago.data.dto.schooldetail.SchoolInfoResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query +import java.time.LocalDate + +interface SchoolRetrofitService { + @GET("api/v1/schools/{schoolId}") + suspend fun getSchool( + @Path("schoolId") schoolId: Long, + ): Response + + @GET("api/v1/schools/{schoolId}/festivals") + suspend fun getSchoolFestivals( + @Path("schoolId") schoolId: Long, + @Query("size") size: Int?, + @Query("isPast") isPast: Boolean?, + @Query("lastFestivalId") lastFestivalId: Int?, + @Query("lastStartDate") lastStartDate: LocalDate?, + ): Response +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/service/SearchRetrofitService.kt b/android/festago/data/src/main/java/com/festago/festago/data/service/SearchRetrofitService.kt new file mode 100644 index 000000000..6f525f989 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/service/SearchRetrofitService.kt @@ -0,0 +1,25 @@ +package com.festago.festago.data.service + +import com.festago.festago.data.dto.artist.ArtistSearchResponse +import com.festago.festago.data.dto.festival.FestivalSearchResponse +import com.festago.festago.data.dto.school.SchoolSearchResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Query + +interface SearchRetrofitService { + @GET("api/v1/search/festivals") + suspend fun searchFestivals( + @Query("keyword") keyword: String, + ): Response> + + @GET("api/v1/search/artists") + suspend fun searchArtists( + @Query("keyword") keyword: String, + ): Response> + + @GET("api/v1/search/schools") + suspend fun searchSchools( + @Query("keyword") keyword: String, + ): Response> +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/util/ResponseExt.kt b/android/festago/data/src/main/java/com/festago/festago/data/util/ResponseExt.kt new file mode 100644 index 000000000..e234ac33c --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/util/ResponseExt.kt @@ -0,0 +1,53 @@ +package com.festago.festago.data.util + +import com.festago.festago.domain.exception.BookmarkLimitExceededException +import com.festago.festago.domain.exception.NetworkException +import com.festago.festago.domain.exception.UnauthorizedException +import retrofit2.Response +import java.net.UnknownHostException + +suspend fun runCatchingResponse( + block: suspend () -> Response, +): Result { + try { + val response = block() + if (response.isSuccessful && response.body() != null) { + return Result.success(response.body()!!) + } + + handleUnauthorizedException(response) + + handleBadRequestException(response) + + return Result.failure( + Throwable( + "{" + + "code: ${response.code()}," + + "message: ${response.message()}, " + + "body: ${response.errorBody()?.string()}" + + "}", + ), + ) + } catch (e: Exception) { + if (e is UnknownHostException) { + return Result.failure(NetworkException()) + } + return Result.failure(e) + } +} + +private fun handleUnauthorizedException(response: Response) { + if (response.code() == 401) { + throw UnauthorizedException() + } +} + +private fun handleBadRequestException(response: Response) { + if (response.code() == 400) { + response.errorBody()?.string()?.let { + if (it.contains("BOOKMARK_LIMIT_EXCEEDED")) { + throw BookmarkLimitExceededException() + } + } + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/util/ResultExt.kt b/android/festago/data/src/main/java/com/festago/festago/data/util/ResultExt.kt new file mode 100644 index 000000000..bc058d668 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/util/ResultExt.kt @@ -0,0 +1,12 @@ +package com.festago.festago.data.util + +suspend fun Result.onSuccessOrCatch(block: suspend (T) -> R): Result { + return try { + onSuccess { return Result.success(block(it)) } + onFailure { return Result.failure(it) } + + throw Throwable("This line should not be reached") + } catch (e: Exception) { + Result.failure(e) + } +} diff --git a/android/festago/data/src/main/java/com/festago/festago/data/util/SharedPrefExt.kt b/android/festago/data/src/main/java/com/festago/festago/data/util/SharedPrefExt.kt new file mode 100644 index 000000000..4bf934298 --- /dev/null +++ b/android/festago/data/src/main/java/com/festago/festago/data/util/SharedPrefExt.kt @@ -0,0 +1,14 @@ +package com.festago.festago.data.util + +import android.content.SharedPreferences +import com.google.gson.GsonBuilder + +inline fun SharedPreferences.putObject(key: String, value: T?) { + val jsonString = GsonBuilder().create().toJson(value) + edit().putString(key, jsonString).apply() +} + +inline fun SharedPreferences.getObject(key: String, default: T?): T? { + val value = getString(key, null) ?: return default + return GsonBuilder().create().fromJson(value, T::class.java) +} diff --git a/android/festago/data/src/test/java/com/festago/festago/data/.gitkeep b/android/festago/data/src/test/java/com/festago/festago/data/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/festago/docs/README.md b/android/festago/docs/README.md deleted file mode 100644 index 22c574328..000000000 --- a/android/festago/docs/README.md +++ /dev/null @@ -1,16 +0,0 @@ -- [x] QR 정보를 받아온다. - -```Gherkin - GIVEN 티켓 ID 를 가지고 있다. - WHEN QR 정보를 요청한다. - THEN QR 정보를 받는다. -``` - -- [x] QR을 생성한다. - - 유효 시간이 얼마나 남았는지 보여준다. - -```Gherkin - GIVEN QR 정보를 받아온 상태이다. - WHEN QR 을 생성한다. - THEN 생성된 QR 이 화면에 노출된다. -``` diff --git a/android/festago/domain-legacy/.gitignore b/android/festago/domain-legacy/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/android/festago/domain-legacy/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/festago/domain-legacy/build.gradle.kts b/android/festago/domain-legacy/build.gradle.kts new file mode 100644 index 000000000..e7815286b --- /dev/null +++ b/android/festago/domain-legacy/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + kotlin("jvm") + id("org.jlleitschuh.gradle.ktlint") +} + +dependencies { + testImplementation("org.junit.jupiter", "junit-jupiter", "5.8.2") + testImplementation("org.assertj", "assertj-core", "3.22.0") + testImplementation("io.kotest", "kotest-runner-junit5", "5.2.3") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") +} + +kotlin.jvmToolchain(17) + +tasks { + compileKotlin { + kotlinOptions.jvmTarget = "17" + } + compileTestKotlin { + kotlinOptions.jvmTarget = "17" + } + test { + useJUnitPlatform() + } +} diff --git a/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ErrorCode.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ErrorCode.kt new file mode 100644 index 000000000..55baeae7c --- /dev/null +++ b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ErrorCode.kt @@ -0,0 +1,9 @@ +package com.festago.festago.model + +sealed class ErrorCode : Throwable() { + + class NEED_STUDENT_VERIFICATION : ErrorCode() + class RESERVE_TICKET_OVER_AMOUNT : ErrorCode() + class TICKET_SOLD_OUT : ErrorCode() + class UNKNOWN : ErrorCode() +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/Festival.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/Festival.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/Festival.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/Festival.kt diff --git a/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/FestivalFilter.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/FestivalFilter.kt new file mode 100644 index 000000000..4f881328f --- /dev/null +++ b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/FestivalFilter.kt @@ -0,0 +1,5 @@ +package com.festago.festago.model + +enum class FestivalFilter { + ALL, PROGRESS, PLANNED, END +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/MemberTicketFestival.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/MemberTicketFestival.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/MemberTicketFestival.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/MemberTicketFestival.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/Reservation.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/Reservation.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/Reservation.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/Reservation.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/ReservationStage.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ReservationStage.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/ReservationStage.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ReservationStage.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/ReservationTicket.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ReservationTicket.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/ReservationTicket.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ReservationTicket.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/ReservationTickets.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ReservationTickets.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/ReservationTickets.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ReservationTickets.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/ReservedTicket.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ReservedTicket.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/ReservedTicket.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/ReservedTicket.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/School.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/School.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/School.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/School.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/Stage.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/Stage.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/Stage.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/Stage.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/StudentVerificationCode.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/StudentVerificationCode.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/StudentVerificationCode.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/StudentVerificationCode.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/TextValidator.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/TextValidator.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/TextValidator.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/TextValidator.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/Ticket.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/Ticket.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/Ticket.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/Ticket.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/TicketCode.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/TicketCode.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/TicketCode.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/TicketCode.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/TicketCondition.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/TicketCondition.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/TicketCondition.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/TicketCondition.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/TicketType.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/TicketType.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/TicketType.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/TicketType.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/UserProfile.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/UserProfile.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/UserProfile.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/UserProfile.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/timer/Timer.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/timer/Timer.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/timer/Timer.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/timer/Timer.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/model/timer/TimerListener.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/timer/TimerListener.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/model/timer/TimerListener.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/model/timer/TimerListener.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/repository/AuthRepository.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/AuthRepository.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/repository/AuthRepository.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/AuthRepository.kt diff --git a/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/FestivalRepository.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/FestivalRepository.kt new file mode 100644 index 000000000..326077ec2 --- /dev/null +++ b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/FestivalRepository.kt @@ -0,0 +1,10 @@ +package com.festago.festago.repository + +import com.festago.festago.model.Festival +import com.festago.festago.model.FestivalFilter +import com.festago.festago.model.Reservation + +interface FestivalRepository { + suspend fun loadFestivals(festivalFilter: FestivalFilter): Result> + suspend fun loadFestivalDetail(festivalId: Long): Result +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/repository/ReservationTicketRepository.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/ReservationTicketRepository.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/repository/ReservationTicketRepository.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/ReservationTicketRepository.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/repository/SchoolRepository.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/SchoolRepository.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/repository/SchoolRepository.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/SchoolRepository.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/repository/SocialAuthRepository.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/SocialAuthRepository.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/repository/SocialAuthRepository.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/SocialAuthRepository.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/repository/StudentVerificationRepository.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/StudentVerificationRepository.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/repository/StudentVerificationRepository.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/StudentVerificationRepository.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/repository/TicketRepository.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/TicketRepository.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/repository/TicketRepository.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/TicketRepository.kt diff --git a/android/festago/domain/src/main/java/com/festago/festago/repository/UserRepository.kt b/android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/UserRepository.kt similarity index 100% rename from android/festago/domain/src/main/java/com/festago/festago/repository/UserRepository.kt rename to android/festago/domain-legacy/src/main/java/com/festago/festago/domain/repository/UserRepository.kt diff --git a/android/festago/domain/src/test/java/com/festago/festago/model/StudentVerificationCodeTest.kt b/android/festago/domain-legacy/src/test/java/com/festago/festago/model/StudentVerificationCodeTest.kt similarity index 100% rename from android/festago/domain/src/test/java/com/festago/festago/model/StudentVerificationCodeTest.kt rename to android/festago/domain-legacy/src/test/java/com/festago/festago/model/StudentVerificationCodeTest.kt diff --git a/android/festago/domain/src/test/java/com/festago/festago/model/TextValidatorTest.kt b/android/festago/domain-legacy/src/test/java/com/festago/festago/model/TextValidatorTest.kt similarity index 100% rename from android/festago/domain/src/test/java/com/festago/festago/model/TextValidatorTest.kt rename to android/festago/domain-legacy/src/test/java/com/festago/festago/model/TextValidatorTest.kt diff --git a/android/festago/domain/build.gradle.kts b/android/festago/domain/build.gradle.kts index e3645b404..e7815286b 100644 --- a/android/festago/domain/build.gradle.kts +++ b/android/festago/domain/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("jvm") + id("org.jlleitschuh.gradle.ktlint") } dependencies { @@ -11,6 +12,8 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") } +kotlin.jvmToolchain(17) + tasks { compileKotlin { kotlinOptions.jvmTarget = "17" diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/exception/BookmarkLimitExeededException.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/exception/BookmarkLimitExeededException.kt new file mode 100644 index 000000000..94e30c942 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/exception/BookmarkLimitExeededException.kt @@ -0,0 +1,5 @@ +package com.festago.festago.domain.exception + +class BookmarkLimitExceededException : Exception() + +fun Throwable.isBookmarkLimitExceeded() = this is BookmarkLimitExceededException diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/exception/NetworkException.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/exception/NetworkException.kt new file mode 100644 index 000000000..c85c57236 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/exception/NetworkException.kt @@ -0,0 +1,4 @@ +package com.festago.festago.domain.exception + +class NetworkException : Exception() +fun Throwable.isNetworkError() = this is NetworkException diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/exception/UnauthorizedException.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/exception/UnauthorizedException.kt new file mode 100644 index 000000000..62a7e4335 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/exception/UnauthorizedException.kt @@ -0,0 +1,6 @@ +package com.festago.festago.domain.exception + +/* 에러 코드가 401 일때 예외 */ +class UnauthorizedException : Exception() + +fun Throwable.isUnauthorized() = this is UnauthorizedException diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/artist/Artist.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/artist/Artist.kt new file mode 100644 index 000000000..de47ac6b3 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/artist/Artist.kt @@ -0,0 +1,7 @@ +package com.festago.festago.domain.model.artist + +data class Artist( + val id: Long, + val name: String, + val imageUrl: String, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/artist/ArtistDetail.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/artist/ArtistDetail.kt new file mode 100644 index 000000000..69e40b2ae --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/artist/ArtistDetail.kt @@ -0,0 +1,11 @@ +package com.festago.festago.domain.model.artist + +import com.festago.festago.domain.model.social.SocialMedia + +data class ArtistDetail( + val id: Int, + val artistName: String, + val profileUrl: String, + val backgroundUrl: String, + val artistMedia: List, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/ArtistBookmark.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/ArtistBookmark.kt new file mode 100644 index 000000000..d2202cbd5 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/ArtistBookmark.kt @@ -0,0 +1,8 @@ +package com.festago.festago.domain.model.bookmark + +import java.time.LocalDateTime + +data class ArtistBookmark( + val artist: ArtistBookmarkInfo, + val createdAt: LocalDateTime, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/ArtistBookmarkInfo.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/ArtistBookmarkInfo.kt new file mode 100644 index 000000000..358315875 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/ArtistBookmarkInfo.kt @@ -0,0 +1,7 @@ +package com.festago.festago.domain.model.bookmark + +data class ArtistBookmarkInfo( + val id: Long, + val name: String, + val profileImageUrl: String +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/BookmarkType.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/BookmarkType.kt new file mode 100644 index 000000000..5f3a91298 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/BookmarkType.kt @@ -0,0 +1,7 @@ +package com.festago.festago.domain.model.bookmark + +enum class BookmarkType { + ARTIST, + FESTIVAL, + SCHOOL, +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/FestivalBookmark.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/FestivalBookmark.kt new file mode 100644 index 000000000..860ec91c5 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/FestivalBookmark.kt @@ -0,0 +1,9 @@ +package com.festago.festago.domain.model.bookmark + +import com.festago.festago.domain.model.festival.Festival +import java.time.LocalDateTime + +class FestivalBookmark( + val festival: Festival, + val createdAt: LocalDateTime +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/FestivalBookmarkOrder.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/FestivalBookmarkOrder.kt new file mode 100644 index 000000000..892ff868f --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/FestivalBookmarkOrder.kt @@ -0,0 +1,6 @@ +package com.festago.festago.domain.model.bookmark + +enum class FestivalBookmarkOrder { + BOOKMARK, + FESTIVAL, +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/SchoolBookmark.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/SchoolBookmark.kt new file mode 100644 index 000000000..7956dbcb4 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/SchoolBookmark.kt @@ -0,0 +1,8 @@ +package com.festago.festago.domain.model.bookmark + +import java.time.LocalDateTime + +data class SchoolBookmark( + val school: SchoolBookmarkInfo, + val createdAt: LocalDateTime +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/SchoolBookmarkInfo.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/SchoolBookmarkInfo.kt new file mode 100644 index 000000000..6a6b04f9a --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/bookmark/SchoolBookmarkInfo.kt @@ -0,0 +1,7 @@ +package com.festago.festago.domain.model.bookmark + +data class SchoolBookmarkInfo( + val id: Long, + val name: String, + val logoUrl: String +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/Festival.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/Festival.kt new file mode 100644 index 000000000..cdc882891 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/Festival.kt @@ -0,0 +1,15 @@ +package com.festago.festago.domain.model.festival + +import com.festago.festago.domain.model.artist.Artist +import com.festago.festago.domain.model.school.School +import java.time.LocalDate + +data class Festival( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val imageUrl: String, + val school: School?, + val artists: List, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/FestivalDetail.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/FestivalDetail.kt new file mode 100644 index 000000000..6036a8de3 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/FestivalDetail.kt @@ -0,0 +1,17 @@ +package com.festago.festago.domain.model.festival + +import com.festago.festago.domain.model.school.School +import com.festago.festago.domain.model.social.SocialMedia +import com.festago.festago.domain.model.stage.Stage +import java.time.LocalDate + +data class FestivalDetail( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val posterImageUrl: String, + val school: School, + val socialMedias: List, + val stages: List, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/FestivalFilter.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/FestivalFilter.kt new file mode 100644 index 000000000..199c54a3f --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/FestivalFilter.kt @@ -0,0 +1,5 @@ +package com.festago.festago.domain.model.festival + +enum class FestivalFilter { + ALL, PROGRESS, PLANNED, END +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/FestivalsPage.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/FestivalsPage.kt new file mode 100644 index 000000000..e324e3641 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/FestivalsPage.kt @@ -0,0 +1,6 @@ +package com.festago.festago.domain.model.festival + +data class FestivalsPage( + val isLastPage: Boolean, + val festivals: List, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/PopularFestivals.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/PopularFestivals.kt new file mode 100644 index 000000000..ea1d76663 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/PopularFestivals.kt @@ -0,0 +1,6 @@ +package com.festago.festago.domain.model.festival + +data class PopularFestivals( + val title: String, + val festivals: List, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/SchoolRegion.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/SchoolRegion.kt new file mode 100644 index 000000000..d9b15877f --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/festival/SchoolRegion.kt @@ -0,0 +1,5 @@ +package com.festago.festago.domain.model.festival + +enum class SchoolRegion { + 서울, 부산, 대구, 인천, 광주, 대전, 울산, 세종, 경기, 강원, 충북, 충남, 전북, 전남, 경북, 경남, 제주 // ktlint-disable enum-entry-name-case +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/nonce/NonceGenerator.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/nonce/NonceGenerator.kt new file mode 100644 index 000000000..62276febc --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/nonce/NonceGenerator.kt @@ -0,0 +1,13 @@ +package com.festago.festago.domain.model.nonce + +class NonceGenerator { + fun generate() = + List((MIN_LENGTH..MAX_LENGTH).random()) { (MIN_CHAR..MAX_CHAR).random() }.joinToString("") + + companion object { + private const val MIN_LENGTH = 3 + private const val MAX_LENGTH = 6 + private const val MIN_CHAR = 'a' + private const val MAX_CHAR = 'z' + } +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/recentsearch/RecentSearchQuery.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/recentsearch/RecentSearchQuery.kt new file mode 100644 index 000000000..197b84bf3 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/recentsearch/RecentSearchQuery.kt @@ -0,0 +1,6 @@ +package com.festago.festago.domain.model.recentsearch + +data class RecentSearchQuery( + val query: String, + val queriedDate: Long, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/school/School.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/school/School.kt new file mode 100644 index 000000000..11936b168 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/school/School.kt @@ -0,0 +1,7 @@ +package com.festago.festago.domain.model.school + +data class School( + val id: Long, + val name: String, + val imageUrl: String, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/school/SchoolInfo.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/school/SchoolInfo.kt new file mode 100644 index 000000000..fdbcf27ce --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/school/SchoolInfo.kt @@ -0,0 +1,11 @@ +package com.festago.festago.domain.model.school + +import com.festago.festago.domain.model.social.SocialMedia + +data class SchoolInfo( + val id: Int, + val schoolName: String, + val logoUrl: String, + val backgroundUrl: String, + val socialMedia: List +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/ArtistSearch.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/ArtistSearch.kt new file mode 100644 index 000000000..fb472d087 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/ArtistSearch.kt @@ -0,0 +1,9 @@ +package com.festago.festago.domain.model.search + +data class ArtistSearch( + val id: Long, + val name: String, + val profileImageUrl: String, + val todayStage: Int, + val upcomingStage: Int, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/FestivalSearch.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/FestivalSearch.kt new file mode 100644 index 000000000..00d06acd2 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/FestivalSearch.kt @@ -0,0 +1,13 @@ +package com.festago.festago.domain.model.search + +import com.festago.festago.domain.model.artist.Artist +import java.time.LocalDate + +data class FestivalSearch( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val imageUrl: String, + val artists: List, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/SchoolSearch.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/SchoolSearch.kt new file mode 100644 index 000000000..dde84b81e --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/search/SchoolSearch.kt @@ -0,0 +1,10 @@ +package com.festago.festago.domain.model.search + +import java.time.LocalDate + +data class SchoolSearch( + val id: Long, + val name: String, + val logoUrl: String, + val upcomingFestivalStartDate: LocalDate?, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMedia.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMedia.kt new file mode 100644 index 000000000..22e9cbed0 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMedia.kt @@ -0,0 +1,8 @@ +package com.festago.festago.domain.model.social + +data class SocialMedia( + val type: SocialMediaType, + val name: String, + val logoUrl: String, + val url: String +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMediaType.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMediaType.kt new file mode 100644 index 000000000..ace6732b4 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/social/SocialMediaType.kt @@ -0,0 +1,9 @@ +package com.festago.festago.domain.model.social + +enum class SocialMediaType { + FACEBOOK, + INSTAGRAM, + YOUTUBE, + X, + NONE, +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/stage/Stage.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/stage/Stage.kt new file mode 100644 index 000000000..89ca13659 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/stage/Stage.kt @@ -0,0 +1,10 @@ +package com.festago.festago.domain.model.stage + +import com.festago.festago.domain.model.artist.Artist +import java.time.LocalDateTime + +class Stage( + val id: Long, + val startDateTime: LocalDateTime, + val artists: List, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/Token.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/Token.kt new file mode 100644 index 000000000..58869af3d --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/Token.kt @@ -0,0 +1,10 @@ +package com.festago.festago.domain.model.user + +import java.time.LocalDateTime + +data class Token( + val token: String, + val expiredAt: LocalDateTime, +) { + fun isExpired() = LocalDateTime.now().isAfter(expiredAt) +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/UserInfo.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/UserInfo.kt new file mode 100644 index 000000000..43b7227ac --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/model/user/UserInfo.kt @@ -0,0 +1,6 @@ +package com.festago.festago.domain.model.user + +data class UserInfo( + val nickname: String, + val profileImageUrl: String, +) diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/ArtistRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/ArtistRepository.kt new file mode 100644 index 000000000..a4de27690 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/ArtistRepository.kt @@ -0,0 +1,17 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.artist.ArtistDetail +import com.festago.festago.domain.model.festival.FestivalsPage +import java.time.LocalDate + +interface ArtistRepository { + suspend fun loadArtistDetail(id: Long, delayTimeMillis: Long = 0L): Result + + suspend fun loadArtistFestivals( + id: Long, + size: Int? = null, + lastFestivalId: Long? = null, + lastStartDate: LocalDate? = null, + isPast: Boolean? = null, + ): Result +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/BookmarkRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/BookmarkRepository.kt new file mode 100644 index 000000000..0737c42a8 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/BookmarkRepository.kt @@ -0,0 +1,37 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.bookmark.ArtistBookmark +import com.festago.festago.domain.model.bookmark.BookmarkType +import com.festago.festago.domain.model.bookmark.FestivalBookmark +import com.festago.festago.domain.model.bookmark.FestivalBookmarkOrder +import com.festago.festago.domain.model.bookmark.SchoolBookmark + +interface BookmarkRepository { + // Festival Bookmark + suspend fun addFestivalBookmark(festivalId: Long): Result + + suspend fun getFestivalBookmarks( + festivalIds: List, + festivalBookmarkOrder: FestivalBookmarkOrder, + ): Result> + + suspend fun getFestivalBookmarkIds(): Result> + + suspend fun deleteFestivalBookmark(festivalId: Long): Result + + // School Bookmark + suspend fun addSchoolBookmark(schoolId: Long): Result + + suspend fun getSchoolBookmarks(): Result> + + suspend fun deleteSchoolBookmark(schoolId: Long): Result + + // Artist Bookmark + suspend fun addArtistBookmark(artistId: Long): Result + + suspend fun getArtistBookmarks(): Result> + + suspend fun deleteArtistBookmark(artistId: Long): Result + + fun isBookmarked(id: Long, type: BookmarkType): Boolean +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/FestivalRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/FestivalRepository.kt new file mode 100644 index 000000000..cedf7aa74 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/FestivalRepository.kt @@ -0,0 +1,21 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.festival.FestivalDetail +import com.festago.festago.domain.model.festival.FestivalFilter +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.model.festival.PopularFestivals +import com.festago.festago.domain.model.festival.SchoolRegion +import java.time.LocalDate + +interface FestivalRepository { + suspend fun loadPopularFestivals(): Result + suspend fun loadFestivals( + schoolRegion: SchoolRegion? = null, + festivalFilter: FestivalFilter? = null, + lastFestivalId: Long? = null, + lastStartDate: LocalDate? = null, + size: Int? = null, + ): Result + + suspend fun loadFestivalDetail(id: Long, delayTimeMillis: Long = 0L): Result +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/RecentSearchRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/RecentSearchRepository.kt new file mode 100644 index 000000000..b24c8c6b8 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/RecentSearchRepository.kt @@ -0,0 +1,10 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.recentsearch.RecentSearchQuery + +interface RecentSearchRepository { + suspend fun insertOrReplaceRecentSearch(searchQuery: String) + suspend fun deleteRecentSearch(searchQuery: String) + suspend fun getRecentSearchQueries(limit: Int): List + suspend fun clearRecentSearches() +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SchoolRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SchoolRepository.kt new file mode 100644 index 000000000..5133ea60e --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SchoolRepository.kt @@ -0,0 +1,16 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.model.school.SchoolInfo +import java.time.LocalDate + +interface SchoolRepository { + suspend fun loadSchoolInfo(schoolId: Long, delayTimeMillis: Long = 0L): Result + suspend fun loadSchoolFestivals( + schoolId: Long, + size: Int? = null, + isPast: Boolean? = null, + lastFestivalId: Int? = null, + lastStartDate: LocalDate? = null, + ): Result +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SearchRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SearchRepository.kt new file mode 100644 index 000000000..276707831 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/SearchRepository.kt @@ -0,0 +1,11 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.search.ArtistSearch +import com.festago.festago.domain.model.search.FestivalSearch +import com.festago.festago.domain.model.search.SchoolSearch + +interface SearchRepository { + suspend fun searchFestivals(searchQuery: String): Result> + suspend fun searchArtists(searchQuery: String): Result> + suspend fun searchSchools(searchQuery: String): Result> +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/domain/repository/UserRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/UserRepository.kt new file mode 100644 index 000000000..d80e0c516 --- /dev/null +++ b/android/festago/domain/src/main/java/com/festago/festago/domain/repository/UserRepository.kt @@ -0,0 +1,17 @@ +package com.festago.festago.domain.repository + +import com.festago.festago.domain.model.user.Token +import com.festago.festago.domain.model.user.UserInfo + +interface UserRepository { + suspend fun isSignRejected(): Boolean + suspend fun isSigned(): Boolean + suspend fun getAccessToken(): Result + suspend fun getRefreshToken(): Result + suspend fun signIn(idToken: String): Result + suspend fun signOut(): Result + suspend fun deleteAccount(): Result + suspend fun rejectSignIn() + suspend fun getUserInfo(): Result + suspend fun clearToken() +} diff --git a/android/festago/domain/src/main/java/com/festago/festago/repository/FestivalRepository.kt b/android/festago/domain/src/main/java/com/festago/festago/repository/FestivalRepository.kt deleted file mode 100644 index db08d52dd..000000000 --- a/android/festago/domain/src/main/java/com/festago/festago/repository/FestivalRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.festago.festago.repository - -import com.festago.festago.model.Festival -import com.festago.festago.model.Reservation - -interface FestivalRepository { - suspend fun loadFestivals(): Result> - suspend fun loadFestivalDetail(festivalId: Long): Result -} diff --git a/android/festago/presentation-legacy/.gitignore b/android/festago/presentation-legacy/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/android/festago/presentation-legacy/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/festago/presentation-legacy/build.gradle.kts b/android/festago/presentation-legacy/build.gradle.kts new file mode 100644 index 000000000..6706372db --- /dev/null +++ b/android/festago/presentation-legacy/build.gradle.kts @@ -0,0 +1,138 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("kotlin-parcelize") + id("kotlin-kapt") + id("org.jlleitschuh.gradle.ktlint") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.festago.festago.presentation" + compileSdk = 34 + + defaultConfig { + minSdk = 28 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getSecretKey("kakao_native_app_key")) + resValue("string", "kakao_redirection_scheme", getSecretKey("kakao_redirection_scheme")) + } + + buildTypes { + debug { + buildConfigField("Boolean", "DEBUG_MODE", "true") + } + release { + isMinifyEnabled = false + buildConfigField("Boolean", "DEBUG_MODE", "false") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + dataBinding { + enable = true + } +} + +tasks.withType().all { + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(project(":common")) + implementation(project(":domain-legacy")) + + // android + implementation("androidx.core:core-ktx:1.10.1") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.9.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + + // hilt + implementation("com.google.dagger:hilt-android:2.44") + kapt("com.google.dagger:hilt-android-compiler:2.44") + // hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } + implementation("com.google.dagger:hilt-android-testing:2.44") + + // recyclerview + implementation("androidx.recyclerview:recyclerview:1.3.1-rc01") + + // lifecycle + implementation("androidx.lifecycle:lifecycle-extensions:2.2.0") + + // glide + implementation("com.github.bumptech.glide:glide:4.15.1") + + // retrofit + implementation("com.squareup.retrofit2:retrofit:2.9.0") + + // junit4 + testImplementation("junit:junit:4.13.2") + testImplementation("androidx.test.ext:junit:1.1.5") + testImplementation("androidx.test:runner:1.5.2") + + // assertJ + testImplementation("org.assertj:assertj-core:3.22.0") + + // android-test + testImplementation("androidx.arch.core:core-testing:2.2.0") + + // mock + testImplementation("io.mockk:mockk-android:1.13.5") + + // espresso + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + + // coroutine + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") + + // viewModel + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") + implementation("androidx.activity:activity-ktx:1.7.2") + implementation("androidx.fragment:fragment-ktx:1.6.0") + + // zxing + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + + // firebase + implementation("com.google.firebase:firebase-messaging-ktx:23.4.0") + + // swiperefreshlayout + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + + // kakao login + implementation("com.kakao.sdk:v2-user:2.12.0") + + // turbine + testImplementation("app.cash.turbine:turbine:1.0.0") + + // inApp Update + implementation("com.google.android.play:app-update-ktx:2.1.0") + + // splash + implementation("androidx.core:core-splashscreen:1.1.0-alpha02") +} + +fun getSecretKey(propertyKey: String): String { + return gradleLocalProperties(rootDir).getProperty(propertyKey) +} diff --git a/android/festago/presentation-legacy/proguard-rules.pro b/android/festago/presentation-legacy/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/android/festago/presentation-legacy/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/festago/presentation-legacy/src/main/AndroidManifest.xml b/android/festago/presentation-legacy/src/main/AndroidManifest.xml new file mode 100644 index 000000000..2f1063f45 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/fcm/FcmMessageType.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/fcm/FcmMessageType.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/fcm/FcmMessageType.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/fcm/FcmMessageType.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/fcm/NotificationManager.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/fcm/NotificationManager.kt similarity index 97% rename from android/festago/app/src/main/java/com/festago/festago/presentation/fcm/NotificationManager.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/fcm/NotificationManager.kt index 6abea300b..15a12572f 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/fcm/NotificationManager.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/fcm/NotificationManager.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import com.festago.festago.R +import com.festago.festago.presentation.R import com.festago.festago.presentation.fcm.FcmMessageType.ENTRY_ALERT import com.festago.festago.presentation.ui.home.HomeActivity import com.festago.festago.presentation.util.checkNotificationPermission diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/fcm/TicketEntryService.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/fcm/TicketEntryService.kt similarity index 90% rename from android/festago/app/src/main/java/com/festago/festago/presentation/fcm/TicketEntryService.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/fcm/TicketEntryService.kt index b2061db31..891d75ad9 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/fcm/TicketEntryService.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/fcm/TicketEntryService.kt @@ -9,7 +9,9 @@ import kotlinx.coroutines.runBlocking class TicketEntryService : FirebaseMessagingService() { - private val notificationManager by lazy { NotificationManager(this) } + private val notificationManager by lazy { + NotificationManager(this) + } override fun onMessageReceived(remoteMessage: RemoteMessage) { when (remoteMessage.notification?.channelId) { @@ -28,7 +30,7 @@ class TicketEntryService : FirebaseMessagingService() { private fun handleEntryAlert(remoteMessage: RemoteMessage) { notificationManager.sendEntryAlertNotification( remoteMessage.notification?.title ?: "", - remoteMessage.notification?.body ?: "" + remoteMessage.notification?.body ?: "", ) } diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/BindingAdapter.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/BindingAdapter.kt similarity index 96% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/BindingAdapter.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/BindingAdapter.kt index 320a72cdf..5c1966fa8 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/BindingAdapter.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/BindingAdapter.kt @@ -6,7 +6,7 @@ import androidx.constraintlayout.motion.widget.MotionLayout import androidx.core.view.doOnLayout import androidx.databinding.BindingAdapter import com.bumptech.glide.Glide -import com.festago.festago.R +import com.festago.festago.presentation.R @BindingAdapter("visibility") fun View.setVisibility(isVisible: Boolean) { diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/customview/OkDialogFragment.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/customview/OkDialogFragment.kt similarity index 97% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/customview/OkDialogFragment.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/customview/OkDialogFragment.kt index 1b3500a8a..c5ffd4c8c 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/customview/OkDialogFragment.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/customview/OkDialogFragment.kt @@ -9,7 +9,7 @@ import android.view.View import android.view.ViewGroup import android.view.WindowManager import androidx.fragment.app.DialogFragment -import com.festago.festago.databinding.FragmentOkDialogBinding +import com.festago.festago.presentation.databinding.FragmentOkDialogBinding import com.festago.festago.presentation.ui.customview.OkDialogFragment.OnClickListener import dagger.hilt.android.AndroidEntryPoint diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/HomeActivity.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/HomeActivity.kt new file mode 100644 index 000000000..7b6020e51 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/HomeActivity.kt @@ -0,0 +1,177 @@ +package com.festago.festago.presentation.ui.home + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.addCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ActivityHomeBinding +import com.festago.festago.presentation.ui.home.festivallist.FestivalListFragment +import com.festago.festago.presentation.ui.home.mypage.MyPageFragment +import com.festago.festago.presentation.ui.home.ticketlist.TicketListFragment +import com.festago.festago.presentation.ui.signin.SignInActivity +import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.requestNotificationPermission +import com.google.android.material.navigation.NavigationBarView +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class HomeActivity : AppCompatActivity() { + + private val binding by lazy { ActivityHomeBinding.inflate(layoutInflater) } + + private val vm: HomeViewModel by viewModels() + + private lateinit var resultLauncher: ActivityResultLauncher + + private val navigationBarView by lazy { binding.nvHome as NavigationBarView } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initBinding() + initView() + initObserve() + initResultLauncher() + initBackPressedDispatcher() + } + + private fun initResultLauncher() { + resultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == SignInActivity.RESULT_NOT_SIGN_IN) { + navigationBarView.selectedItemId = R.id.item_festival + } + } + initNotificationPermission() + } + + private fun initBinding() { + setContentView(binding.root) + } + + private fun initView() { + navigationBarView.setOnItemSelectedListener { + vm.selectItem(getItemType(it.itemId)) + true + } + + binding.fabTicket.setOnClickListener { + navigationBarView.selectedItemId = R.id.item_ticket + } + + changeFragment() + } + + private fun initObserve() { + repeatOnStarted(this) { + vm.event.collect { event -> + when (event) { + is HomeEvent.ShowSignIn -> showSignIn() + } + } + } + + repeatOnStarted(this) { + vm.selectedItem.collect { homeItemType -> + when (homeItemType) { + HomeItemType.FESTIVAL_LIST -> showFestivalList() + HomeItemType.TICKET_LIST -> showTicketList() + HomeItemType.MY_PAGE -> showMyPage() + } + } + } + } + + private fun initNotificationPermission() { + val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted: Boolean -> + if (!isGranted) { + Toast.makeText( + this, + getString(R.string.home_notification_permission_denied), + Toast.LENGTH_SHORT, + ).show() + } + } + requestNotificationPermission(requestPermissionLauncher) + } + + private fun getItemType(menuItemId: Int): HomeItemType { + return when (menuItemId) { + R.id.item_festival -> HomeItemType.FESTIVAL_LIST + R.id.item_mypage -> HomeItemType.MY_PAGE + R.id.item_ticket -> HomeItemType.TICKET_LIST + else -> throw IllegalArgumentException("menu item id not found") + } + } + + private fun showFestivalList() { + changeFragment() + binding.fabTicket.isSelected = false + } + + private fun showTicketList() { + changeFragment() + binding.fabTicket.isSelected = true + } + + private fun showMyPage() { + changeFragment() + binding.fabTicket.isSelected = false + } + + private fun showSignIn() { + resultLauncher.launch(SignInActivity.getIntent(this)) + } + + private fun initBackPressedDispatcher() { + var backPressedTime = START_BACK_PRESSED_TIME + onBackPressedDispatcher.addCallback { + if ((System.currentTimeMillis() - backPressedTime) > FINISH_BACK_PRESSED_TIME) { + backPressedTime = System.currentTimeMillis() + Toast.makeText( + this@HomeActivity, + getString(R.string.home_back_pressed), + Toast.LENGTH_SHORT, + ).show() + } else { + finish() + } + } + } + + private inline fun changeFragment() { + val tag = T::class.java.name + val fragmentTransaction = supportFragmentManager.beginTransaction() + + supportFragmentManager.fragments.forEach { fragment -> + fragmentTransaction.hide(fragment) + } + + var targetFragment = supportFragmentManager.findFragmentByTag(tag) + + if (targetFragment == null) { + targetFragment = T::class.java.newInstance() + fragmentTransaction.add(R.id.fcv_home_container, targetFragment, tag) + } else { + fragmentTransaction.show(targetFragment) + } + + fragmentTransaction.commit() + } + + companion object { + private const val START_BACK_PRESSED_TIME = 0L + private const val FINISH_BACK_PRESSED_TIME = 3000L + fun getIntent(context: Context): Intent { + return Intent(context, HomeActivity::class.java) + } + } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/HomeEvent.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/HomeEvent.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/HomeEvent.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/HomeEvent.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/HomeItemType.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/HomeItemType.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/HomeItemType.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/HomeItemType.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemViewHolder.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemViewHolder.kt similarity index 90% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemViewHolder.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemViewHolder.kt index b07700b58..a303399c7 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemViewHolder.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalItemViewHolder.kt @@ -3,7 +3,7 @@ package com.festago.festago.presentation.ui.home.festivallist import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import com.festago.festago.databinding.ItemFestivalListBinding +import com.festago.festago.presentation.databinding.ItemFestivalListBinding class FestivalItemViewHolder( private val binding: ItemFestivalListBinding, diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListAdapter.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListAdapter.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListAdapter.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListAdapter.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListEvent.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListEvent.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListEvent.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListEvent.kt diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt new file mode 100644 index 000000000..ba09c5237 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt @@ -0,0 +1,138 @@ +package com.festago.festago.presentation.ui.home.festivallist + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.GridLayoutManager +import com.festago.festago.model.FestivalFilter +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.FragmentFestivalListBinding +import com.festago.festago.presentation.ui.home.ticketlist.TicketListFragment +import com.festago.festago.presentation.ui.ticketreserve.TicketReserveActivity +import com.festago.festago.presentation.util.repeatOnStarted +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class FestivalListFragment : Fragment(R.layout.fragment_festival_list) { + + private var _binding: FragmentFestivalListBinding? = null + private val binding get() = _binding!! + + private val vm: FestivalListViewModel by viewModels() + + private lateinit var adapter: FestivalListAdapter + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentFestivalListBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initObserve() + initView() + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { + binding.uiState = it + updateUi(it) + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { + handleEvent(it) + } + } + } + + private val Int.dp: Int get() = (this / resources.displayMetrics.density).toInt() + + private fun initView() { + adapter = FestivalListAdapter() + binding.rvFestivalList.adapter = adapter + + initFestivalListSpanSize() + initRefresh() + initFestivalFilters() + if (vm.uiState.value is FestivalListUiState.Loading) { + loadFestivalsBy(binding.cgFilterOption.checkedChipId) + } + } + + private fun initFestivalListSpanSize() { + binding.rvFestivalList.layoutManager.apply { + if (this is GridLayoutManager) { + val spanSize = (resources.displayMetrics.widthPixels.dp / 160) + spanCount = when { + spanSize < 2 -> 2 + spanSize > 4 -> 4 + else -> spanSize + } + } + } + } + + private fun initRefresh() { + binding.srlFestivalList.setOnRefreshListener { + loadFestivalsBy(binding.cgFilterOption.checkedChipId) + binding.srlFestivalList.isRefreshing = false + } + } + + private fun initFestivalFilters() { + binding.cgFilterOption.setOnCheckedStateChangeListener { group, _ -> + loadFestivalsBy(checkedChipId = group.checkedChipId) + } + } + + private fun loadFestivalsBy(checkedChipId: Int) { + when (checkedChipId) { + R.id.chipProgress -> vm.loadFestivals(FestivalFilter.PROGRESS) + R.id.chipPlanned -> vm.loadFestivals(FestivalFilter.PLANNED) + R.id.chipEnd -> vm.loadFestivals(FestivalFilter.END) + } + } + + private fun updateUi(uiState: FestivalListUiState) { + when (uiState) { + is FestivalListUiState.Loading, + is FestivalListUiState.Error, + -> Unit + + is FestivalListUiState.Success -> handleSuccess(uiState) + } + } + + private fun handleSuccess(uiState: FestivalListUiState.Success) { + adapter.submitList(uiState.festivals) + } + + private fun handleEvent(event: FestivalListEvent) { + when (event) { + is FestivalListEvent.ShowTicketReserve -> { + removeTicketListFragment() + startActivity(TicketReserveActivity.getIntent(requireContext(), event.festivalId)) + } + } + } + + private fun removeTicketListFragment() { + parentFragmentManager.findFragmentByTag(TicketListFragment::class.java.name)?.let { + parentFragmentManager.beginTransaction().remove(it).commit() + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListUiState.kt new file mode 100644 index 000000000..8ba2e7774 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListUiState.kt @@ -0,0 +1,18 @@ +package com.festago.festago.presentation.ui.home.festivallist + +sealed interface FestivalListUiState { + object Loading : FestivalListUiState + + data class Success( + val festivals: List, + ) : FestivalListUiState { + val hasFestival get() = festivals.isNotEmpty() + } + + object Error : FestivalListUiState + + val shouldShowSuccess get() = this is Success && hasFestival + val shouldShowSuccessAndEmpty get() = this is Success && !hasFestival + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt new file mode 100644 index 000000000..66808564b --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt @@ -0,0 +1,64 @@ +package com.festago.festago.presentation.ui.home.festivallist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.model.FestivalFilter +import com.festago.festago.presentation.ui.home.festivallist.FestivalListEvent.ShowTicketReserve +import com.festago.festago.repository.FestivalRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FestivalListViewModel @Inject constructor( + private val festivalRepository: FestivalRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FestivalListUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun loadFestivals(festivalFilter: FestivalFilter) { + viewModelScope.launch { + festivalRepository.loadFestivals(festivalFilter) + .onSuccess { + _uiState.value = FestivalListUiState.Success( + festivals = it.map { festival -> + FestivalItemUiState( + id = festival.id, + name = festival.name, + startDate = festival.startDate, + endDate = festival.endDate, + thumbnail = festival.thumbnail, + onFestivalDetail = ::showTicketReserve, + ) + }, + ) + }.onFailure { + _uiState.value = FestivalListUiState.Error + analyticsHelper.logNetworkFailure(KEY_LOAD_FESTIVALS_LOG, it.message.toString()) + } + } + } + + fun showTicketReserve(festivalId: Long) { + viewModelScope.launch { + _event.emit(ShowTicketReserve(festivalId)) + } + } + + companion object { + private const val KEY_LOAD_FESTIVALS_LOG = "load_festivals" + } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageEvent.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageEvent.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageEvent.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageEvent.kt diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt new file mode 100644 index 000000000..9089b0d39 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt @@ -0,0 +1,134 @@ +package com.festago.festago.presentation.ui.home.mypage + +import android.app.AlertDialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.FragmentMyPageBinding +import com.festago.festago.presentation.ui.home.HomeActivity +import com.festago.festago.presentation.ui.selectschool.SelectSchoolActivity +import com.festago.festago.presentation.ui.signin.SignInActivity +import com.festago.festago.presentation.ui.tickethistory.TicketHistoryActivity +import com.festago.festago.presentation.util.repeatOnStarted +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MyPageFragment : Fragment(R.layout.fragment_my_page) { + + private var _binding: FragmentMyPageBinding? = null + private val binding get() = _binding!! + + private val vm: MyPageViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentMyPageBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initObserve() + initView() + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { uiState -> + handleUiState(uiState) + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { event -> + handleEvent(event) + } + } + } + + private fun handleUiState(uiState: MyPageUiState) { + binding.uiState = uiState + when (uiState) { + is MyPageUiState.Loading, is MyPageUiState.Error -> Unit + + is MyPageUiState.Success -> handleSuccess(uiState) + } + } + + private fun handleEvent(event: MyPageEvent) { + when (event) { + is MyPageEvent.ShowSignIn -> handleShowSignInEvent() + is MyPageEvent.SignOutSuccess -> handleSignOutSuccessEvent() + is MyPageEvent.DeleteAccountSuccess -> handleDeleteAccountSuccess() + is MyPageEvent.ShowTicketHistory -> handleShowTicketHistory() + is MyPageEvent.ShowConfirmDelete -> handleShowConfirmDelete() + } + } + + private fun handleShowSignInEvent() { + startActivity(SignInActivity.getIntent(requireContext())) + } + + private fun handleSignOutSuccessEvent() { + restartHome() + } + + private fun handleDeleteAccountSuccess() { + restartHome() + } + + private fun restartHome() { + requireActivity().finishAffinity() + startActivity(HomeActivity.getIntent(requireContext())) + } + + private fun handleShowTicketHistory() { + startActivity(TicketHistoryActivity.getIntent(requireContext())) + } + + private fun handleShowConfirmDelete() { + val dialog = AlertDialog.Builder(requireContext()).apply { + setTitle(getString(R.string.confirm_delete_dialog_title)) + setMessage(getString(R.string.confirm_delete_dialog_message)) + setPositiveButton(getString(R.string.confirm_delete_dialog_yes)) { dialog, _ -> + vm.deleteAccount() + dialog.dismiss() + } + setNegativeButton(getString(R.string.confirm_delete_dialog_no)) { dialog, _ -> + dialog.dismiss() + } + } + dialog.show() + } + + private fun initView() { + binding.vm = vm + + vm.loadUserInfo() + + binding.srlMyPage.setOnRefreshListener { + vm.loadUserInfo() + binding.srlMyPage.isRefreshing = false + } + + binding.tvSchoolAuthorization.setOnClickListener { + startActivity(SelectSchoolActivity.getIntent(requireContext())) + } + } + + private fun handleSuccess(uiState: MyPageUiState.Success) { + binding.successState = uiState + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageUiState.kt diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModel.kt new file mode 100644 index 000000000..77cf032bf --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModel.kt @@ -0,0 +1,109 @@ +package com.festago.festago.presentation.ui.home.mypage + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.repository.AuthRepository +import com.festago.festago.repository.TicketRepository +import com.festago.festago.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MyPageViewModel @Inject constructor( + private val userRepository: UserRepository, + private val ticketRepository: TicketRepository, + private val authRepository: AuthRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _uiState = MutableStateFlow(MyPageUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun loadUserInfo() { + if (!authRepository.isSigned) { + viewModelScope.launch { + _event.emit(MyPageEvent.ShowSignIn) + _uiState.value = MyPageUiState.Error + } + return + } + viewModelScope.launch { + val deferredUserProfile = async { userRepository.loadUserProfile() } + val deferredHistoryTicket = async { ticketRepository.loadHistoryTickets(size = 1) } + + runCatching { + _uiState.value = MyPageUiState.Success( + userProfile = deferredUserProfile.await().getOrThrow(), + ticket = deferredHistoryTicket.await().getOrThrow().firstOrNull(), + ) + }.onFailure { + _uiState.value = MyPageUiState.Error + analyticsHelper.logNetworkFailure( + key = KEY_LOAD_USER_INFO, + value = it.message.toString(), + ) + } + } + } + + fun signOut() { + viewModelScope.launch { + authRepository.signOut() + .onSuccess { + _event.emit(MyPageEvent.SignOutSuccess) + _uiState.value = MyPageUiState.Error + }.onFailure { + analyticsHelper.logNetworkFailure( + key = KEY_SIGN_OUT, + value = it.message.toString(), + ) + } + } + } + + fun showConfirmDelete() { + viewModelScope.launch { + _event.emit(MyPageEvent.ShowConfirmDelete) + } + } + + fun deleteAccount() { + viewModelScope.launch { + authRepository.deleteAccount() + .onSuccess { + _event.emit(MyPageEvent.DeleteAccountSuccess) + _uiState.value = MyPageUiState.Error + }.onFailure { + analyticsHelper.logNetworkFailure( + key = KEY_DELETE_ACCOUNT, + value = it.message.toString(), + ) + } + } + } + + fun showTicketHistory() { + viewModelScope.launch { + _event.emit(MyPageEvent.ShowTicketHistory) + } + } + + companion object { + private const val KEY_LOAD_USER_INFO = "loadUserInfo" + private const val KEY_SIGN_OUT = "KEY_SIGN_OUT" + private const val KEY_DELETE_ACCOUNT = "KEY_DELETE_ACCOUNT" + } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListAdapter.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListAdapter.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListAdapter.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListAdapter.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListEvent.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListEvent.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListEvent.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListEvent.kt diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListFragment.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListFragment.kt new file mode 100644 index 000000000..b52258f88 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListFragment.kt @@ -0,0 +1,116 @@ +package com.festago.festago.presentation.ui.home.ticketlist + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.FragmentTicketListBinding +import com.festago.festago.presentation.ui.ticketentry.TicketEntryActivity +import com.festago.festago.presentation.util.repeatOnStarted +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TicketListFragment : Fragment(R.layout.fragment_ticket_list) { + + private var _binding: FragmentTicketListBinding? = null + private val binding get() = _binding!! + + private lateinit var adapter: TicketListAdapter + + private lateinit var resultLauncher: ActivityResultLauncher + + private val vm: TicketListViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentTicketListBinding.inflate(inflater) + binding.lifecycleOwner = viewLifecycleOwner + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initObserve() + initView() + initActivityResult() + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { + binding.uiState = it + updateUi(it) + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { event -> + handleEvent(event) + } + } + } + + private fun updateUi(uiState: TicketListUiState) { + when (uiState) { + is TicketListUiState.Loading, + is TicketListUiState.Error, + -> Unit + + is TicketListUiState.Success -> { + adapter.submitList(uiState.tickets) + } + } + } + + private fun handleEvent(event: TicketListEvent) { + when (event) { + is TicketListEvent.ShowTicketEntry -> showTicketEntry(event) + } + } + + private fun showTicketEntry(event: TicketListEvent.ShowTicketEntry) { + resultLauncher.launch( + TicketEntryActivity.getIntent( + context = requireContext(), + ticketId = event.ticketId, + ), + ) + } + + private fun initActivityResult() { + resultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == TicketEntryActivity.RESULT_OK) { + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.fcv_home_container, TicketListFragment()).commit() + } + } + } + + private fun initView() { + adapter = TicketListAdapter() + binding.rvTicketList.adapter = adapter + vm.loadCurrentTickets() + initRefresh() + } + + private fun initRefresh() { + binding.srlTicketList.setOnRefreshListener { + vm.loadCurrentTickets() + binding.srlTicketList.isRefreshing = false + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemViewHolder.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemViewHolder.kt similarity index 94% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemViewHolder.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemViewHolder.kt index ebd8045c4..3165e3b24 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemViewHolder.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListItemViewHolder.kt @@ -4,9 +4,9 @@ import android.view.LayoutInflater import android.view.ViewGroup import android.widget.Button import androidx.recyclerview.widget.RecyclerView -import com.festago.festago.R -import com.festago.festago.databinding.ItemTicketListBinding import com.festago.festago.model.TicketCondition +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemTicketListBinding import java.time.format.DateTimeFormatter class TicketListItemViewHolder( diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModel.kt similarity index 93% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModel.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModel.kt index ce15bfd2b..379be7b25 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModel.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModel.kt @@ -2,8 +2,8 @@ package com.festago.festago.presentation.ui.home.ticketlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.logNetworkFailure +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure import com.festago.festago.repository.TicketRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservationCompleteActivity.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservationCompleteActivity.kt similarity index 94% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservationCompleteActivity.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservationCompleteActivity.kt index 145bbb44d..1b45eb5e7 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservationCompleteActivity.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservationCompleteActivity.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import com.festago.festago.databinding.ActivityReservationCompleteBinding +import com.festago.festago.presentation.databinding.ActivityReservationCompleteBinding import com.festago.festago.presentation.util.getParcelableExtraCompat import dagger.hilt.android.AndroidEntryPoint diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservedTicketArg.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservedTicketArg.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservedTicketArg.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/reservationcomplete/ReservedTicketArg.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt similarity index 95% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt index 595016076..97457a100 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolActivity.kt @@ -6,8 +6,8 @@ import android.os.Bundle import android.widget.ArrayAdapter import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import com.festago.festago.R -import com.festago.festago.databinding.ActivitySelectSchoolBinding +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ActivitySelectSchoolBinding import com.festago.festago.presentation.ui.studentverification.StudentVerificationActivity import com.festago.festago.presentation.util.repeatOnStarted import dagger.hilt.android.AndroidEntryPoint diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolEvent.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolEvent.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolEvent.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolEvent.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt similarity index 95% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt index 5a7bd892e..e1c7881c1 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModel.kt @@ -2,8 +2,8 @@ package com.festago.festago.presentation.ui.selectschool import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.logNetworkFailure +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure import com.festago.festago.repository.SchoolRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/signin/SignInActivity.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/signin/SignInActivity.kt new file mode 100644 index 000000000..17b3ecc01 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/signin/SignInActivity.kt @@ -0,0 +1,111 @@ +package com.festago.festago.presentation.ui.signin + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan +import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ActivitySignInBinding +import com.festago.festago.presentation.ui.customview.OkDialogFragment +import com.festago.festago.presentation.ui.home.HomeActivity +import com.festago.festago.presentation.util.repeatOnStarted +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SignInActivity : AppCompatActivity() { + + private lateinit var binding: ActivitySignInBinding + + private val vm: SignInViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initBinding() + initView() + initObserve() + } + + private fun initBinding() { + binding = ActivitySignInBinding.inflate(layoutInflater) + setContentView(binding.root) + } + + private fun initView() { + binding.lifecycleOwner = this + binding.vm = vm + initComment() + initBackPressed() + } + + private fun initBackPressed() { + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + setResult(RESULT_NOT_SIGN_IN, intent) + finish() + } + } + this.onBackPressedDispatcher.addCallback(this, callback) + } + + private fun initObserve() { + repeatOnStarted(this) { + vm.event.collect { event -> + when (event) { + is SignInEvent.SignInSuccess -> handleSuccessEvent() + is SignInEvent.SignInFailure -> handleFailureEvent() + } + } + } + } + + private fun initComment() { + val spannableStringBuilder = SpannableStringBuilder( + getString(R.string.mypage_tv_signin_description), + ).apply { + setSpan( + ForegroundColorSpan(getColor(R.color.seed)), + COLOR_SPAN_START_INDEX, + COLOR_SPAN_END_INDEX, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + binding.tvLoginDescription.text = spannableStringBuilder + } + + private fun handleSuccessEvent() { + showHomeWithFinish() + } + + private fun handleFailureEvent() { + val dialog = OkDialogFragment.newInstance(FAILURE_SIGN_IN).apply { + listener = OkDialogFragment.OnClickListener { + showHomeWithFinish() + } + } + dialog.show(supportFragmentManager, OkDialogFragment::class.java.name) + } + + private fun showHomeWithFinish() { + val intent = HomeActivity.getIntent(this).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + finishAffinity() + startActivity(intent) + } + + companion object { + private const val COLOR_SPAN_START_INDEX = 0 + private const val COLOR_SPAN_END_INDEX = 4 + + private const val FAILURE_SIGN_IN = "로그인에 실패했습니다." + const val RESULT_NOT_SIGN_IN = 1 + fun getIntent(context: Context): Intent { + return Intent(context, SignInActivity::class.java) + } + } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt new file mode 100644 index 000000000..a3be0826d --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt @@ -0,0 +1,37 @@ +package com.festago.festago.presentation.ui.signin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SignInViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event + + fun signIn() { + viewModelScope.launch { + authRepository.signIn().onSuccess { + _event.emit(SignInEvent.SignInSuccess) + }.onFailure { + _event.emit(SignInEvent.SignInFailure) + analyticsHelper.logNetworkFailure(KEY_SIGN_IN_LOG, it.message.toString()) + } + } + } + + companion object { + private const val KEY_SIGN_IN_LOG = "KEY_SIGN_IN_LOG" + } +} diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt new file mode 100644 index 000000000..541007137 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt @@ -0,0 +1,101 @@ +package com.festago.festago.presentation.ui.splash + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.appcompat.app.AlertDialog +import androidx.core.splashscreen.SplashScreen +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.festago.festago.presentation.BuildConfig +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ActivitySplashBinding +import com.festago.festago.presentation.ui.home.HomeActivity +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.install.model.UpdateAvailability +import com.kakao.sdk.common.KakaoSdk +import dagger.hilt.android.AndroidEntryPoint + +@SuppressLint("CustomSplashScreen") +@AndroidEntryPoint +class SplashActivity : ComponentActivity() { + + val binding by lazy { + ActivitySplashBinding.inflate(layoutInflater) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { true } + initKakaoSdk() + checkAppUpdate(splashScreen) + setContentView(binding.root) + } + + private fun initKakaoSdk() { + Log.d("SplashActivity", "initKakaoSdk: ${BuildConfig.KAKAO_NATIVE_APP_KEY}") + KakaoSdk.init(this.applicationContext, BuildConfig.KAKAO_NATIVE_APP_KEY) + } + + private fun checkAppUpdate(splashScreen: SplashScreen) { + val appUpdateManager = AppUpdateManagerFactory.create(this) + appUpdateManager.appUpdateInfo + .addOnSuccessListener { appUpdateInfo -> + handleOnSuccess(appUpdateInfo, splashScreen) + }.addOnFailureListener { + showHome() + } + } + + private fun handleOnSuccess(appUpdateInfo: AppUpdateInfo, splashScreen: SplashScreen) { + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + splashScreen.setKeepOnScreenCondition { false } + requestUpdate() + } else { + showHome() + } + } + + private fun showHome() { + startActivity(HomeActivity.getIntent(this)) + finish() + } + + private fun requestUpdate() { + AlertDialog.Builder(this).apply { + setTitle(getString(R.string.splash_app_update_request_dialog_title)) + setMessage(getString(R.string.splash_app_update_request_dialog_message)) + setNegativeButton(R.string.ok_dialog_btn_cancel) { _, _ -> + handleCancelUpdate() + } + setPositiveButton(R.string.ok_dialog_btn_ok) { _, _ -> + handleOkUpdate() + } + setCancelable(false) + }.show() + } + + private fun handleCancelUpdate() { + Toast.makeText( + this@SplashActivity, + getString(R.string.splash_app_update_denied), + Toast.LENGTH_SHORT, + ).show() + finish() + } + + private fun handleOkUpdate() { + navigateToAppStore() + finish() + } + + private fun navigateToAppStore() { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName"))) + finish() + } +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationActivity.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationActivity.kt similarity index 93% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationActivity.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationActivity.kt index 349ec05d4..2f9b619ab 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationActivity.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationActivity.kt @@ -5,11 +5,12 @@ import android.content.Intent import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import com.festago.festago.R -import com.festago.festago.databinding.ActivityStudentVerificationBinding +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ActivityStudentVerificationBinding import com.festago.festago.presentation.ui.customview.OkDialogFragment import com.festago.festago.presentation.ui.home.HomeActivity import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.setOnSingleClickListener import dagger.hilt.android.AndroidEntryPoint import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -43,7 +44,7 @@ class StudentVerificationActivity : AppCompatActivity() { } private fun initRequestVerificationCodeBtn(schoolId: Long) { - binding.btnRequestVerificationCode.setOnClickListener { + binding.btnRequestVerificationCode.setOnSingleClickListener { vm.sendVerificationCode(binding.tieUserName.text.toString(), schoolId) } } diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationEvent.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationEvent.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationEvent.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationEvent.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModel.kt similarity index 97% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModel.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModel.kt index 39f0ca38b..102005fc5 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModel.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModel.kt @@ -2,8 +2,8 @@ package com.festago.festago.presentation.ui.studentverification import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.logNetworkFailure +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure import com.festago.festago.model.StudentVerificationCode import com.festago.festago.model.timer.Timer import com.festago.festago.model.timer.TimerListener diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryActivity.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryActivity.kt similarity index 97% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryActivity.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryActivity.kt index 74094c06b..085172bfc 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryActivity.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryActivity.kt @@ -8,7 +8,7 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.content.res.ResourcesCompat -import com.festago.festago.databinding.ActivityTicketEntryBinding +import com.festago.festago.presentation.databinding.ActivityTicketEntryBinding import com.festago.festago.presentation.fcm.TicketEntryService import com.festago.festago.presentation.util.repeatOnStarted import com.google.zxing.BarcodeFormat diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryUiState.kt similarity index 98% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryUiState.kt index 1ab505157..d45407b2b 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryUiState.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryUiState.kt @@ -1,11 +1,11 @@ package com.festago.festago.presentation.ui.ticketentry -import com.festago.festago.R import com.festago.festago.model.Ticket import com.festago.festago.model.TicketCode import com.festago.festago.model.TicketCondition.AFTER_ENTRY import com.festago.festago.model.TicketCondition.AWAY import com.festago.festago.model.TicketCondition.BEFORE_ENTRY +import com.festago.festago.presentation.R sealed interface TicketEntryUiState { object Loading : TicketEntryUiState diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModel.kt similarity index 96% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModel.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModel.kt index ae14081a2..0e60abe49 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModel.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModel.kt @@ -2,8 +2,8 @@ package com.festago.festago.presentation.ui.ticketentry import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.logNetworkFailure +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure import com.festago.festago.model.Ticket import com.festago.festago.model.TicketCode import com.festago.festago.model.timer.Timer diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryActivity.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryActivity.kt similarity index 95% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryActivity.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryActivity.kt index 46019fce1..0292967b1 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryActivity.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryActivity.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import com.festago.festago.databinding.ActivityTicketHistoryBinding +import com.festago.festago.presentation.databinding.ActivityTicketHistoryBinding import com.festago.festago.presentation.util.repeatOnStarted import dagger.hilt.android.AndroidEntryPoint diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryAdapter.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryAdapter.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryAdapter.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryAdapter.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryItemUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryItemUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryItemUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryItemUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewHolder.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewHolder.kt similarity index 90% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewHolder.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewHolder.kt index 0e4c66658..fd192731e 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewHolder.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewHolder.kt @@ -3,7 +3,7 @@ package com.festago.festago.presentation.ui.tickethistory import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import com.festago.festago.databinding.ItemTicketHistoryBinding +import com.festago.festago.presentation.databinding.ItemTicketHistoryBinding class TicketHistoryViewHolder( val binding: ItemTicketHistoryBinding, diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModel.kt similarity index 93% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModel.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModel.kt index f1cf12e61..99f46f002 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModel.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModel.kt @@ -2,8 +2,8 @@ package com.festago.festago.presentation.ui.tickethistory import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.logNetworkFailure +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure import com.festago.festago.repository.TicketRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/ReservationFestivalUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/ReservationFestivalUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/ReservationFestivalUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/ReservationFestivalUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveActivity.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveActivity.kt similarity index 87% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveActivity.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveActivity.kt index 529e364dd..fc6fa2dbc 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveActivity.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveActivity.kt @@ -6,10 +6,11 @@ import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.ConcatAdapter -import com.festago.festago.R -import com.festago.festago.databinding.ActivityTicketReserveBinding +import com.festago.festago.model.ErrorCode import com.festago.festago.model.ReservationTicket import com.festago.festago.model.ReservedTicket +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ActivityTicketReserveBinding import com.festago.festago.presentation.ui.customview.OkDialogFragment import com.festago.festago.presentation.ui.reservationcomplete.ReservationCompleteActivity import com.festago.festago.presentation.ui.reservationcomplete.ReservedTicketArg @@ -75,7 +76,7 @@ class TicketReserveActivity : AppCompatActivity() { ) is ReserveTicketSuccess -> handleReserveTicketSuccess(event.reservedTicket) - is ReserveTicketFailed -> handleReserveTicketFailed() + is ReserveTicketFailed -> handleReserveTicketFailed(event.errorCode) is ShowSignIn -> handleShowSignIn() } @@ -113,8 +114,14 @@ class TicketReserveActivity : AppCompatActivity() { finish() } - private fun handleReserveTicketFailed() { - OkDialogFragment.newInstance("예약에 실패하였습니다.") + private fun handleReserveTicketFailed(errorCode: ErrorCode) { + val message: String = when (errorCode) { + is ErrorCode.TICKET_SOLD_OUT -> getString(R.string.ticket_reserve_dialog_sold_out) + is ErrorCode.RESERVE_TICKET_OVER_AMOUNT -> getString(R.string.ticket_reserve_dialog_over_amount) + is ErrorCode.NEED_STUDENT_VERIFICATION -> getString(R.string.ticket_reserve_dialog_need_student_verification) + is ErrorCode.UNKNOWN -> getString(R.string.ticket_reserve_dialog_unknown) + } + OkDialogFragment.newInstance(message) .show(supportFragmentManager, OkDialogFragment::class.java.name) } diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveEvent.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveEvent.kt similarity index 80% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveEvent.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveEvent.kt index 5cc0f3cc7..3ed05c64f 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveEvent.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveEvent.kt @@ -1,5 +1,6 @@ package com.festago.festago.presentation.ui.ticketreserve +import com.festago.festago.model.ErrorCode import com.festago.festago.model.ReservationTicket import com.festago.festago.model.ReservedTicket import java.time.LocalDateTime @@ -11,6 +12,6 @@ sealed interface TicketReserveEvent { ) : TicketReserveEvent class ReserveTicketSuccess(val reservedTicket: ReservedTicket) : TicketReserveEvent - object ReserveTicketFailed : TicketReserveEvent + class ReserveTicketFailed(val errorCode: ErrorCode) : TicketReserveEvent object ShowSignIn : TicketReserveEvent } diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveItemUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveItemUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveItemUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveItemUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveUiState.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveUiState.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveUiState.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveUiState.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModel.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModel.kt similarity index 78% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModel.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModel.kt index 05c056faf..a3819ad31 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModel.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModel.kt @@ -2,8 +2,9 @@ package com.festago.festago.presentation.ui.ticketreserve import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.festago.festago.analytics.AnalyticsHelper -import com.festago.festago.analytics.logNetworkFailure +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.model.ErrorCode import com.festago.festago.model.ReservationStage import com.festago.festago.repository.AuthRepository import com.festago.festago.repository.FestivalRepository @@ -49,12 +50,9 @@ class TicketReserveViewModel @Inject constructor( ), stages = it.reservationStages.toTicketReserveItems(), ) - }.onFailure { + }.onFailure { error -> _uiState.value = TicketReserveUiState.Error - analyticsHelper.logNetworkFailure( - KEY_LOAD_RESERVATION_LOG, - it.message.toString(), - ) + analyticsHelper.logNetworkFailure(KEY_LOAD_RESERVATION_LOG, error.message ?: "") } } } @@ -70,8 +68,12 @@ class TicketReserveViewModel @Inject constructor( reservationTickets.sortedByTicketTypes(), ), ) - }.onFailure { + }.onFailure { error -> _uiState.value = TicketReserveUiState.Error + analyticsHelper.logNetworkFailure( + KEY_SHOW_TICKET_TYPES_LOG, + error.message ?: "", + ) } } else { _event.emit(TicketReserveEvent.ShowSignIn) @@ -84,8 +86,13 @@ class TicketReserveViewModel @Inject constructor( ticketRepository.reserveTicket(ticketId) .onSuccess { _event.emit(TicketReserveEvent.ReserveTicketSuccess(it)) - }.onFailure { - _event.emit(TicketReserveEvent.ReserveTicketFailed) + }.onFailure { error -> + if (error is ErrorCode) { + _event.emit(TicketReserveEvent.ReserveTicketFailed(error)) + } else { + _event.emit(TicketReserveEvent.ReserveTicketFailed(ErrorCode.UNKNOWN())) + analyticsHelper.logNetworkFailure(KEY_RESERVE_TICKET, error.message ?: "") + } } } } @@ -108,5 +115,7 @@ class TicketReserveViewModel @Inject constructor( companion object { private const val KEY_LOAD_RESERVATION_LOG = "load_reservation" + private const val KEY_SHOW_TICKET_TYPES_LOG = "show_ticket_types" + private const val KEY_RESERVE_TICKET = "reserve_ticket" } } diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/adapter/TicketReserveAdapter.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/adapter/TicketReserveAdapter.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/adapter/TicketReserveAdapter.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/adapter/TicketReserveAdapter.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/adapter/TicketReserveHeaderAdapter.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/adapter/TicketReserveHeaderAdapter.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/adapter/TicketReserveHeaderAdapter.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/adapter/TicketReserveHeaderAdapter.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/BottomSheetReservationTicketArg.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/BottomSheetReservationTicketArg.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/BottomSheetReservationTicketArg.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/BottomSheetReservationTicketArg.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/BottomSheetTicketTypeArg.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/BottomSheetTicketTypeArg.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/BottomSheetTicketTypeArg.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/BottomSheetTicketTypeArg.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomItem.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomItem.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomItem.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomItem.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetAdapter.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetAdapter.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetAdapter.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetAdapter.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetCallback.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetCallback.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetCallback.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetCallback.kt diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetFragment.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetFragment.kt similarity index 89% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetFragment.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetFragment.kt index d665e2b94..a31f66ddc 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetFragment.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomSheetFragment.kt @@ -5,7 +5,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.lifecycle.ViewModelProvider -import com.festago.festago.databinding.FragmentTicketReserveBottomSheetBinding +import androidx.recyclerview.widget.DividerItemDecoration +import com.festago.festago.presentation.databinding.FragmentTicketReserveBottomSheetBinding import com.festago.festago.presentation.ui.ticketreserve.TicketReserveViewModel import com.festago.festago.presentation.util.getParcelableArrayListCompat import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -22,11 +23,11 @@ class TicketReserveBottomSheetFragment : BottomSheetDialogFragment() { private val ticketTypeAdapter = TicketReserveBottomSheetAdapter { ticketId -> binding.selectedTicketTypeId = ticketId binding.btnReserveTicket.isEnabled = true + binding.tvTicketTypePrompt.visibility = View.INVISIBLE } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val viewModelProvider = ViewModelProvider(requireActivity()) vm = viewModelProvider[TicketReserveViewModel::class.java] } @@ -37,6 +38,7 @@ class TicketReserveBottomSheetFragment : BottomSheetDialogFragment() { savedInstanceState: Bundle?, ): View { _binding = FragmentTicketReserveBottomSheetBinding.inflate(inflater) + dialog?.setCanceledOnTouchOutside(false) binding.lifecycleOwner = viewLifecycleOwner return binding.root } @@ -56,6 +58,7 @@ class TicketReserveBottomSheetFragment : BottomSheetDialogFragment() { private fun initView() { binding.rvTicketTypes.adapter = ticketTypeAdapter + binding.rvTicketTypes.addItemDecoration(DividerItemDecoration(requireContext(), 1)) val onReserve: (Int) -> Unit = { id -> vm.reserveTicket(id) } binding.onReserve = onReserve binding.btnReserveTicket.isEnabled = false diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomViewHolder.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomViewHolder.kt similarity index 92% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomViewHolder.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomViewHolder.kt index a4995ec59..3b2168253 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomViewHolder.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/bottomsheet/TicketReserveBottomViewHolder.kt @@ -3,8 +3,8 @@ package com.festago.festago.presentation.ui.ticketreserve.bottomsheet import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import com.festago.festago.R -import com.festago.festago.databinding.ItemTicketReserveBottomSheetBinding +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemTicketReserveBottomSheetBinding class TicketReserveBottomViewHolder( private val binding: ItemTicketReserveBottomSheetBinding, diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveHeaderViewHolder.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveHeaderViewHolder.kt similarity index 92% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveHeaderViewHolder.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveHeaderViewHolder.kt index 6548ed102..1c45c70cd 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveHeaderViewHolder.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveHeaderViewHolder.kt @@ -5,8 +5,8 @@ import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.festago.festago.R -import com.festago.festago.databinding.ItemTicketReserveHeaderBinding +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemTicketReserveHeaderBinding import com.festago.festago.presentation.ui.ticketreserve.ReservationFestivalUiState import java.time.format.DateTimeFormatter diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveViewHolder.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveViewHolder.kt similarity index 95% rename from android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveViewHolder.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveViewHolder.kt index 09fe4df95..be38e83f9 100644 --- a/android/festago/app/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveViewHolder.kt +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/ui/ticketreserve/viewHolder/TicketReserveViewHolder.kt @@ -3,9 +3,9 @@ package com.festago.festago.presentation.ui.ticketreserve.viewHolder import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import com.festago.festago.R -import com.festago.festago.databinding.ItemTicketReserveBinding import com.festago.festago.model.TicketType +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemTicketReserveBinding import com.festago.festago.presentation.ui.ticketreserve.TicketReserveItemUiState import java.time.format.DateTimeFormatter diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/util/LifecycleOwnerUtil.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/LifecycleOwnerUtil.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/util/LifecycleOwnerUtil.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/LifecycleOwnerUtil.kt diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/ParcelizeUtil.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/ParcelizeUtil.kt new file mode 100644 index 000000000..abdef748d --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/ParcelizeUtil.kt @@ -0,0 +1,34 @@ +package com.festago.festago.presentation.util + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.BundleCompat + +@Suppress("DEPRECATION") +inline fun Bundle.getParcelableCompat(key: String): T? { + return if (Build.VERSION.SDK_INT >= 33) { + getParcelable(key, T::class.java) + } else { + getParcelable(key) + } +} + +@Suppress("DEPRECATION") +inline fun Bundle.getParcelableArrayListCompat(key: String): ArrayList? { + return if (Build.VERSION.SDK_INT >= 33) { + getParcelableArrayList(key, T::class.java) + } else { + getParcelableArrayList(key) + } +} + +inline fun Intent.getParcelableExtraCompat(key: String): T? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val bundle = extras ?: return null + return BundleCompat.getParcelable(bundle, key, T::class.java) + } + @Suppress("DEPRECATION") + return getParcelableExtra(key) as? T +} diff --git a/android/festago/app/src/main/java/com/festago/festago/presentation/util/PermissionUtil.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/PermissionUtil.kt similarity index 100% rename from android/festago/app/src/main/java/com/festago/festago/presentation/util/PermissionUtil.kt rename to android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/PermissionUtil.kt diff --git a/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/SingleClickUtil.kt b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/SingleClickUtil.kt new file mode 100644 index 000000000..ae4b96588 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/java/com/festago/festago/presentation/util/SingleClickUtil.kt @@ -0,0 +1,30 @@ +package com.festago.festago.presentation.util + +import android.os.SystemClock +import android.view.View +import androidx.databinding.BindingAdapter + +class OnSingleClickListener( + private var interval: Int = 600, + private var onSingleClick: (View) -> Unit, +) : View.OnClickListener { + + private var lastClickTime: Long = 0 + + override fun onClick(v: View) { + val elapsedRealtime = SystemClock.elapsedRealtime() + if ((elapsedRealtime - lastClickTime) < interval) { + return + } + lastClickTime = elapsedRealtime + onSingleClick(v) + } +} + +@BindingAdapter("onSingleClick") +fun View.setOnSingleClickListener(onSingleClick: (View) -> Unit) { + val oneClick = OnSingleClickListener { + onSingleClick(it) + } + setOnClickListener(oneClick) +} diff --git a/android/festago/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/festago/presentation-legacy/src/main/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from android/festago/app/src/main/res/drawable-v24/ic_launcher_foreground.xml rename to android/festago/presentation-legacy/src/main/res/drawable-v24/ic_launcher_foreground.xml diff --git a/android/festago/presentation-legacy/src/main/res/drawable/bg_chip_festival_list_selected.xml b/android/festago/presentation-legacy/src/main/res/drawable/bg_chip_festival_list_selected.xml new file mode 100644 index 000000000..b92ade845 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/res/drawable/bg_chip_festival_list_selected.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/festago/app/src/main/res/drawable/bg_custom_dialog.xml b/android/festago/presentation-legacy/src/main/res/drawable/bg_custom_dialog.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/bg_custom_dialog.xml rename to android/festago/presentation-legacy/src/main/res/drawable/bg_custom_dialog.xml diff --git a/android/festago/app/src/main/res/drawable/bg_mypage_kakao_login.xml b/android/festago/presentation-legacy/src/main/res/drawable/bg_mypage_kakao_login.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/bg_mypage_kakao_login.xml rename to android/festago/presentation-legacy/src/main/res/drawable/bg_mypage_kakao_login.xml diff --git a/android/festago/app/src/main/res/drawable/bg_ticket_gradient_primary.xml b/android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_gradient_primary.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/bg_ticket_gradient_primary.xml rename to android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_gradient_primary.xml diff --git a/android/festago/app/src/main/res/drawable/bg_ticket_gradient_secondary.xml b/android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_gradient_secondary.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/bg_ticket_gradient_secondary.xml rename to android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_gradient_secondary.xml diff --git a/android/festago/app/src/main/res/drawable/bg_ticket_gradient_tertiary.xml b/android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_gradient_tertiary.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/bg_ticket_gradient_tertiary.xml rename to android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_gradient_tertiary.xml diff --git a/android/festago/app/src/main/res/drawable/bg_ticket_reserve_bottom_sheet.xml b/android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_reserve_bottom_sheet.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/bg_ticket_reserve_bottom_sheet.xml rename to android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_reserve_bottom_sheet.xml diff --git a/android/festago/app/src/main/res/drawable/bg_ticket_reserve_bottom_sheet_item.xml b/android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_reserve_bottom_sheet_item.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/bg_ticket_reserve_bottom_sheet_item.xml rename to android/festago/presentation-legacy/src/main/res/drawable/bg_ticket_reserve_bottom_sheet_item.xml diff --git a/android/festago/app/src/main/res/drawable/btn_circle_primary.xml b/android/festago/presentation-legacy/src/main/res/drawable/btn_circle_primary.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/btn_circle_primary.xml rename to android/festago/presentation-legacy/src/main/res/drawable/btn_circle_primary.xml diff --git a/android/festago/app/src/main/res/drawable/btn_circle_secondary.xml b/android/festago/presentation-legacy/src/main/res/drawable/btn_circle_secondary.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/btn_circle_secondary.xml rename to android/festago/presentation-legacy/src/main/res/drawable/btn_circle_secondary.xml diff --git a/android/festago/app/src/main/res/drawable/btn_circle_tertiary.xml b/android/festago/presentation-legacy/src/main/res/drawable/btn_circle_tertiary.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/btn_circle_tertiary.xml rename to android/festago/presentation-legacy/src/main/res/drawable/btn_circle_tertiary.xml diff --git a/android/festago/app/src/main/res/drawable/btn_festa.xml b/android/festago/presentation-legacy/src/main/res/drawable/btn_festa.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/btn_festa.xml rename to android/festago/presentation-legacy/src/main/res/drawable/btn_festa.xml diff --git a/android/festago/app/src/main/res/drawable/btn_festa_disabled.xml b/android/festago/presentation-legacy/src/main/res/drawable/btn_festa_disabled.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/btn_festa_disabled.xml rename to android/festago/presentation-legacy/src/main/res/drawable/btn_festa_disabled.xml diff --git a/android/festago/app/src/main/res/drawable/btn_festa_normal.xml b/android/festago/presentation-legacy/src/main/res/drawable/btn_festa_normal.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/btn_festa_normal.xml rename to android/festago/presentation-legacy/src/main/res/drawable/btn_festa_normal.xml diff --git a/android/festago/app/src/main/res/drawable/ic_baseline_error.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_baseline_error.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_baseline_error.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_baseline_error.xml diff --git a/android/festago/app/src/main/res/drawable/ic_bottom_navigation_festival.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_bottom_navigation_festival.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_bottom_navigation_festival.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_bottom_navigation_festival.xml diff --git a/android/festago/app/src/main/res/drawable/ic_bottom_navigation_user.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_bottom_navigation_user.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_bottom_navigation_user.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_bottom_navigation_user.xml diff --git a/android/festago/app/src/main/res/drawable/ic_festago_coupon.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_festago_coupon.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_festago_coupon.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_festago_coupon.xml diff --git a/android/festago/app/src/main/res/drawable/ic_festago_logo_background.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_festago_logo_background.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_festago_logo_background.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_festago_logo_background.xml diff --git a/android/festago/app/src/main/res/drawable/ic_festago_logo_foreground.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_festago_logo_foreground.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_festago_logo_foreground.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_festago_logo_foreground.xml diff --git a/android/festago/app/src/main/res/drawable/ic_image_placeholder.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_image_placeholder.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_image_placeholder.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_image_placeholder.xml diff --git a/android/festago/app/src/main/res/drawable/ic_launcher_background.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_launcher_background.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_launcher_background.xml diff --git a/android/festago/app/src/main/res/drawable/ic_mypage_kakao_logo.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_mypage_kakao_logo.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_mypage_kakao_logo.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_mypage_kakao_logo.xml diff --git a/android/festago/app/src/main/res/drawable/ic_renew.xml b/android/festago/presentation-legacy/src/main/res/drawable/ic_renew.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/ic_renew.xml rename to android/festago/presentation-legacy/src/main/res/drawable/ic_renew.xml diff --git a/android/festago/presentation-legacy/src/main/res/drawable/img_festago_home_logo.xml b/android/festago/presentation-legacy/src/main/res/drawable/img_festago_home_logo.xml new file mode 100644 index 000000000..1694c060c --- /dev/null +++ b/android/festago/presentation-legacy/src/main/res/drawable/img_festago_home_logo.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/android/festago/app/src/main/res/drawable/img_festago_logo.xml b/android/festago/presentation-legacy/src/main/res/drawable/img_festago_logo.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/img_festago_logo.xml rename to android/festago/presentation-legacy/src/main/res/drawable/img_festago_logo.xml diff --git a/android/festago/app/src/main/res/drawable/img_festago_qr.png b/android/festago/presentation-legacy/src/main/res/drawable/img_festago_qr.png similarity index 100% rename from android/festago/app/src/main/res/drawable/img_festago_qr.png rename to android/festago/presentation-legacy/src/main/res/drawable/img_festago_qr.png diff --git a/android/festago/app/src/main/res/drawable/img_mypage_logo.xml b/android/festago/presentation-legacy/src/main/res/drawable/img_mypage_logo.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/img_mypage_logo.xml rename to android/festago/presentation-legacy/src/main/res/drawable/img_mypage_logo.xml diff --git a/android/festago/app/src/main/res/drawable/img_ticket.xml b/android/festago/presentation-legacy/src/main/res/drawable/img_ticket.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/img_ticket.xml rename to android/festago/presentation-legacy/src/main/res/drawable/img_ticket.xml diff --git a/android/festago/app/src/main/res/drawable/menu_selector_color.xml b/android/festago/presentation-legacy/src/main/res/drawable/menu_selector_color.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/menu_selector_color.xml rename to android/festago/presentation-legacy/src/main/res/drawable/menu_selector_color.xml diff --git a/android/festago/app/src/main/res/drawable/menu_selector_color_inverse.xml b/android/festago/presentation-legacy/src/main/res/drawable/menu_selector_color_inverse.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/menu_selector_color_inverse.xml rename to android/festago/presentation-legacy/src/main/res/drawable/menu_selector_color_inverse.xml diff --git a/android/festago/app/src/main/res/drawable/pb_ticket_remain_time_primary.xml b/android/festago/presentation-legacy/src/main/res/drawable/pb_ticket_remain_time_primary.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/pb_ticket_remain_time_primary.xml rename to android/festago/presentation-legacy/src/main/res/drawable/pb_ticket_remain_time_primary.xml diff --git a/android/festago/app/src/main/res/drawable/pb_ticket_remain_time_secondary.xml b/android/festago/presentation-legacy/src/main/res/drawable/pb_ticket_remain_time_secondary.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/pb_ticket_remain_time_secondary.xml rename to android/festago/presentation-legacy/src/main/res/drawable/pb_ticket_remain_time_secondary.xml diff --git a/android/festago/app/src/main/res/drawable/pb_ticket_remain_time_tertiary.xml b/android/festago/presentation-legacy/src/main/res/drawable/pb_ticket_remain_time_tertiary.xml similarity index 100% rename from android/festago/app/src/main/res/drawable/pb_ticket_remain_time_tertiary.xml rename to android/festago/presentation-legacy/src/main/res/drawable/pb_ticket_remain_time_tertiary.xml diff --git a/android/festago/presentation-legacy/src/main/res/drawable/text_chip_festival_list_selected.xml b/android/festago/presentation-legacy/src/main/res/drawable/text_chip_festival_list_selected.xml new file mode 100644 index 000000000..f7ec5e8b0 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/res/drawable/text_chip_festival_list_selected.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/festago/app/src/main/res/layout-land/activity_home.xml b/android/festago/presentation-legacy/src/main/res/layout-land/activity_home.xml similarity index 100% rename from android/festago/app/src/main/res/layout-land/activity_home.xml rename to android/festago/presentation-legacy/src/main/res/layout-land/activity_home.xml diff --git a/android/festago/app/src/main/res/layout/activity_home.xml b/android/festago/presentation-legacy/src/main/res/layout/activity_home.xml similarity index 100% rename from android/festago/app/src/main/res/layout/activity_home.xml rename to android/festago/presentation-legacy/src/main/res/layout/activity_home.xml diff --git a/android/festago/app/src/main/res/layout/activity_reservation_complete.xml b/android/festago/presentation-legacy/src/main/res/layout/activity_reservation_complete.xml similarity index 100% rename from android/festago/app/src/main/res/layout/activity_reservation_complete.xml rename to android/festago/presentation-legacy/src/main/res/layout/activity_reservation_complete.xml diff --git a/android/festago/app/src/main/res/layout/activity_select_school.xml b/android/festago/presentation-legacy/src/main/res/layout/activity_select_school.xml similarity index 100% rename from android/festago/app/src/main/res/layout/activity_select_school.xml rename to android/festago/presentation-legacy/src/main/res/layout/activity_select_school.xml diff --git a/android/festago/app/src/main/res/layout/activity_sign_in.xml b/android/festago/presentation-legacy/src/main/res/layout/activity_sign_in.xml similarity index 100% rename from android/festago/app/src/main/res/layout/activity_sign_in.xml rename to android/festago/presentation-legacy/src/main/res/layout/activity_sign_in.xml diff --git a/android/festago/app/src/main/res/layout/activity_splash.xml b/android/festago/presentation-legacy/src/main/res/layout/activity_splash.xml similarity index 100% rename from android/festago/app/src/main/res/layout/activity_splash.xml rename to android/festago/presentation-legacy/src/main/res/layout/activity_splash.xml diff --git a/android/festago/app/src/main/res/layout/activity_student_verification.xml b/android/festago/presentation-legacy/src/main/res/layout/activity_student_verification.xml similarity index 100% rename from android/festago/app/src/main/res/layout/activity_student_verification.xml rename to android/festago/presentation-legacy/src/main/res/layout/activity_student_verification.xml diff --git a/android/festago/app/src/main/res/layout/activity_ticket_entry.xml b/android/festago/presentation-legacy/src/main/res/layout/activity_ticket_entry.xml similarity index 100% rename from android/festago/app/src/main/res/layout/activity_ticket_entry.xml rename to android/festago/presentation-legacy/src/main/res/layout/activity_ticket_entry.xml diff --git a/android/festago/app/src/main/res/layout/activity_ticket_history.xml b/android/festago/presentation-legacy/src/main/res/layout/activity_ticket_history.xml similarity index 100% rename from android/festago/app/src/main/res/layout/activity_ticket_history.xml rename to android/festago/presentation-legacy/src/main/res/layout/activity_ticket_history.xml diff --git a/android/festago/app/src/main/res/layout/activity_ticket_reserve.xml b/android/festago/presentation-legacy/src/main/res/layout/activity_ticket_reserve.xml similarity index 100% rename from android/festago/app/src/main/res/layout/activity_ticket_reserve.xml rename to android/festago/presentation-legacy/src/main/res/layout/activity_ticket_reserve.xml diff --git a/android/festago/presentation-legacy/src/main/res/layout/fragment_festival_list.xml b/android/festago/presentation-legacy/src/main/res/layout/fragment_festival_list.xml new file mode 100644 index 000000000..14da2ce1b --- /dev/null +++ b/android/festago/presentation-legacy/src/main/res/layout/fragment_festival_list.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/app/src/main/res/layout/fragment_my_page.xml b/android/festago/presentation-legacy/src/main/res/layout/fragment_my_page.xml similarity index 100% rename from android/festago/app/src/main/res/layout/fragment_my_page.xml rename to android/festago/presentation-legacy/src/main/res/layout/fragment_my_page.xml diff --git a/android/festago/app/src/main/res/layout/fragment_ok_dialog.xml b/android/festago/presentation-legacy/src/main/res/layout/fragment_ok_dialog.xml similarity index 100% rename from android/festago/app/src/main/res/layout/fragment_ok_dialog.xml rename to android/festago/presentation-legacy/src/main/res/layout/fragment_ok_dialog.xml diff --git a/android/festago/app/src/main/res/layout/fragment_ticket_list.xml b/android/festago/presentation-legacy/src/main/res/layout/fragment_ticket_list.xml similarity index 100% rename from android/festago/app/src/main/res/layout/fragment_ticket_list.xml rename to android/festago/presentation-legacy/src/main/res/layout/fragment_ticket_list.xml diff --git a/android/festago/presentation-legacy/src/main/res/layout/fragment_ticket_reserve_bottom_sheet.xml b/android/festago/presentation-legacy/src/main/res/layout/fragment_ticket_reserve_bottom_sheet.xml new file mode 100644 index 000000000..384feb9f2 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/res/layout/fragment_ticket_reserve_bottom_sheet.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/app/src/main/res/layout/item_festival_list.xml b/android/festago/presentation-legacy/src/main/res/layout/item_festival_list.xml similarity index 100% rename from android/festago/app/src/main/res/layout/item_festival_list.xml rename to android/festago/presentation-legacy/src/main/res/layout/item_festival_list.xml diff --git a/android/festago/app/src/main/res/layout/item_select_school.xml b/android/festago/presentation-legacy/src/main/res/layout/item_select_school.xml similarity index 100% rename from android/festago/app/src/main/res/layout/item_select_school.xml rename to android/festago/presentation-legacy/src/main/res/layout/item_select_school.xml diff --git a/android/festago/app/src/main/res/layout/item_ticket_history.xml b/android/festago/presentation-legacy/src/main/res/layout/item_ticket_history.xml similarity index 100% rename from android/festago/app/src/main/res/layout/item_ticket_history.xml rename to android/festago/presentation-legacy/src/main/res/layout/item_ticket_history.xml diff --git a/android/festago/app/src/main/res/layout/item_ticket_list.xml b/android/festago/presentation-legacy/src/main/res/layout/item_ticket_list.xml similarity index 100% rename from android/festago/app/src/main/res/layout/item_ticket_list.xml rename to android/festago/presentation-legacy/src/main/res/layout/item_ticket_list.xml diff --git a/android/festago/app/src/main/res/layout/item_ticket_reserve.xml b/android/festago/presentation-legacy/src/main/res/layout/item_ticket_reserve.xml similarity index 97% rename from android/festago/app/src/main/res/layout/item_ticket_reserve.xml rename to android/festago/presentation-legacy/src/main/res/layout/item_ticket_reserve.xml index c1df86cff..f1835d3db 100644 --- a/android/festago/app/src/main/res/layout/item_ticket_reserve.xml +++ b/android/festago/presentation-legacy/src/main/res/layout/item_ticket_reserve.xml @@ -111,11 +111,11 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="16dp" android:layout_marginTop="28dp" - android:onClick="@{() -> stage.onShowStageTickets.invoke(stage.id, stage.startTime)}" android:text="@string/ticket_reserve_btn_reserve_ticket" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/tvAuthGuideStudent" /> + app:layout_constraintTop_toBottomOf="@id/tvAuthGuideStudent" + app:onSingleClick="@{() -> stage.onShowStageTickets.invoke(stage.id, stage.startTime)}" /> diff --git a/android/festago/app/src/main/res/layout/item_ticket_reserve_bottom_sheet.xml b/android/festago/presentation-legacy/src/main/res/layout/item_ticket_reserve_bottom_sheet.xml similarity index 100% rename from android/festago/app/src/main/res/layout/item_ticket_reserve_bottom_sheet.xml rename to android/festago/presentation-legacy/src/main/res/layout/item_ticket_reserve_bottom_sheet.xml diff --git a/android/festago/app/src/main/res/layout/item_ticket_reserve_header.xml b/android/festago/presentation-legacy/src/main/res/layout/item_ticket_reserve_header.xml similarity index 94% rename from android/festago/app/src/main/res/layout/item_ticket_reserve_header.xml rename to android/festago/presentation-legacy/src/main/res/layout/item_ticket_reserve_header.xml index 221d4abaa..eb30c1b19 100644 --- a/android/festago/app/src/main/res/layout/item_ticket_reserve_header.xml +++ b/android/festago/presentation-legacy/src/main/res/layout/item_ticket_reserve_header.xml @@ -33,12 +33,13 @@ android:textSize="16sp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tvUniversityName" + android:layout_marginBottom="20dp" tools:text="2023.07.03 - 2023.07.09" /> diff --git a/android/festago/app/src/main/res/menu/menu_bottom_navigation.xml b/android/festago/presentation-legacy/src/main/res/menu/menu_bottom_navigation.xml similarity index 100% rename from android/festago/app/src/main/res/menu/menu_bottom_navigation.xml rename to android/festago/presentation-legacy/src/main/res/menu/menu_bottom_navigation.xml diff --git a/android/festago/app/src/main/res/mipmap-anydpi-v26/ic_festago_logo.xml b/android/festago/presentation-legacy/src/main/res/mipmap-anydpi-v26/ic_festago_logo.xml similarity index 100% rename from android/festago/app/src/main/res/mipmap-anydpi-v26/ic_festago_logo.xml rename to android/festago/presentation-legacy/src/main/res/mipmap-anydpi-v26/ic_festago_logo.xml diff --git a/android/festago/app/src/main/res/mipmap-anydpi-v26/ic_festago_logo_round.xml b/android/festago/presentation-legacy/src/main/res/mipmap-anydpi-v26/ic_festago_logo_round.xml similarity index 100% rename from android/festago/app/src/main/res/mipmap-anydpi-v26/ic_festago_logo_round.xml rename to android/festago/presentation-legacy/src/main/res/mipmap-anydpi-v26/ic_festago_logo_round.xml diff --git a/android/festago/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/festago/presentation-legacy/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from android/festago/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to android/festago/presentation-legacy/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/android/festago/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/festago/presentation-legacy/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from android/festago/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to android/festago/presentation-legacy/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/android/festago/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/festago/presentation-legacy/src/main/res/mipmap-hdpi/ic_launcher.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-hdpi/ic_launcher.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-hdpi/ic_launcher.webp diff --git a/android/festago/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/festago/presentation-legacy/src/main/res/mipmap-hdpi/ic_launcher_round.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-hdpi/ic_launcher_round.webp diff --git a/android/festago/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/festago/presentation-legacy/src/main/res/mipmap-mdpi/ic_launcher.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-mdpi/ic_launcher.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-mdpi/ic_launcher.webp diff --git a/android/festago/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/festago/presentation-legacy/src/main/res/mipmap-mdpi/ic_launcher_round.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-mdpi/ic_launcher_round.webp diff --git a/android/festago/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/festago/presentation-legacy/src/main/res/mipmap-xhdpi/ic_launcher.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-xhdpi/ic_launcher.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-xhdpi/ic_launcher.webp diff --git a/android/festago/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/festago/presentation-legacy/src/main/res/mipmap-xhdpi/ic_launcher_round.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-xhdpi/ic_launcher_round.webp diff --git a/android/festago/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/festago/presentation-legacy/src/main/res/mipmap-xxhdpi/ic_launcher.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-xxhdpi/ic_launcher.webp diff --git a/android/festago/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/festago/presentation-legacy/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp diff --git a/android/festago/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/festago/presentation-legacy/src/main/res/mipmap-xxxhdpi/ic_launcher.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-xxxhdpi/ic_launcher.webp diff --git a/android/festago/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/festago/presentation-legacy/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp similarity index 100% rename from android/festago/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp rename to android/festago/presentation-legacy/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/android/festago/app/src/main/res/values-night/colors.xml b/android/festago/presentation-legacy/src/main/res/values-night/colors.xml similarity index 100% rename from android/festago/app/src/main/res/values-night/colors.xml rename to android/festago/presentation-legacy/src/main/res/values-night/colors.xml diff --git a/android/festago/app/src/main/res/values-night/themes.xml b/android/festago/presentation-legacy/src/main/res/values-night/themes.xml similarity index 100% rename from android/festago/app/src/main/res/values-night/themes.xml rename to android/festago/presentation-legacy/src/main/res/values-night/themes.xml diff --git a/android/festago/app/src/main/res/values/colors.xml b/android/festago/presentation-legacy/src/main/res/values/colors.xml similarity index 100% rename from android/festago/app/src/main/res/values/colors.xml rename to android/festago/presentation-legacy/src/main/res/values/colors.xml diff --git a/android/festago/app/src/main/res/values/dimens.xml b/android/festago/presentation-legacy/src/main/res/values/dimens.xml similarity index 100% rename from android/festago/app/src/main/res/values/dimens.xml rename to android/festago/presentation-legacy/src/main/res/values/dimens.xml diff --git a/android/festago/presentation-legacy/src/main/res/values/strings.xml b/android/festago/presentation-legacy/src/main/res/values/strings.xml new file mode 100644 index 000000000..8cf6589d1 --- /dev/null +++ b/android/festago/presentation-legacy/src/main/res/values/strings.xml @@ -0,0 +1,150 @@ + + + 페스타고 + + + yyyy.MM.dd + + + 입장전 + 입장완료 + 외출중 + + + 재학생용 + 방문객용 + 기타 + + + 업데이트 알림 + 새로운 페스타고를 사용하기 위해 업데이트 해주세요. + 업데이트 후 정상 사용가능합니다. + 페스타고 실행 중 문제가 발생했습니다. 페스타고로 문의해주세요. + + + HH:mm 티켓 활성화 + 티켓 제시 + 입장 전 + 입장 완료 + 외출중 + + + 입장 전 + 입장 완료 + 외출중 + 티켓 조회 과정에 문제가 발생했습니다. + + + 축제 목록 + 티켓 목록 + 마이페이지 + 알림 권한을 거부했습니다 :( + 한 번 더 누르면 앱이 종료됩니다 + + + %1s ~ %1s + yyyy.MM.dd + MM.dd (E) HH:mm + [라인업] + [예매 가능 티켓] + %1$s(%2$s/%3$s) + ", " + ⚠️ 재학생용 티켓예매를 위해 사전에 학교 인증이 필요합니다. + 축제 조회에 실패했습니다. + 티켓 예매 + (%1$s/%2$s) + 예매 하기 + MM월 dd일 HH:mm 오픈예정 + 로그인 후 예매 + 예매 유형을 선택해주세요 + 공연이 매진되었습니다 + 이미 예매한 공연입니다 + 학생 인증이 필요합니다 + 알 수 없는 오류가 발생했습니다 + + + yyyy.MM.dd + 진행중 + 진행 예정 + 지난 축제 + 축제 조회에 실패했습니다. + 조회된 축제가 없습니다. + %s - %s + + + 티켓 목록 + yyyy.MM.dd HH:mm + HH:mm 티켓 활성화 + 입장하기 + %d번 + 무대 시작 시간 %s + 입장 시작 시간 %s + 사용할 수 있는 티켓이 없습니다 + 티켓 목록 조회에 실패했습니다. + + + 확인 + 취소 + + + 카카오로 로그인 + 페스타고로\n대학교 축제를 즐겨보세요. + + + 예매에 성공했습니다! + 나의 티켓 번호 + HH:mm + yyyy.MM.dd + [입장 가능 시간] %s + + + 계정 + 학교 인증 + 로그아웃 + 탈퇴하기 + 예매 목록 + [무대 시작 시간] + [입장 시작 시간] + [입장 번호] + [예매 일자] + 더보기 > + 예매 내역이 존재하지 않습니다. + yyyy.MM.dd. HH:mm + 마이페이지 정보 받아오기에 실패했습니다. + + + 예매 목록 + [무대 시작 시간] + [입장 시작 시간] + [입장 번호] + %d번 + [예매 일자] + 티켓 조회에 실패했습니다. + 조회된 티켓이 없습니다. + + + 정말 탈퇴하시겠어요? + 탈퇴 버튼 선택 시, 계정은 삭제되며 복구되지 않습니다. + 탈퇴 + 취소 + + + + 학교 이메일 + 인증 코드 + 인증 번호 받기 + 인증 번호 확인 + \@%s + mm:ss + 학교 정보 받아오기에 실패했습니다. + + + 다음 + 학교 선택 + 학교 목록 불러오기에 실패했습니다. + 학교 선택 + + + 공연 입장 알림 + + diff --git a/android/festago/app/src/main/res/values/style.xml b/android/festago/presentation-legacy/src/main/res/values/style.xml similarity index 76% rename from android/festago/app/src/main/res/values/style.xml rename to android/festago/presentation-legacy/src/main/res/values/style.xml index 004ea7295..d071718c5 100644 --- a/android/festago/app/src/main/res/values/style.xml +++ b/android/festago/presentation-legacy/src/main/res/values/style.xml @@ -22,4 +22,10 @@ 50% + + diff --git a/android/festago/app/src/main/res/values/themes.xml b/android/festago/presentation-legacy/src/main/res/values/themes.xml similarity index 100% rename from android/festago/app/src/main/res/values/themes.xml rename to android/festago/presentation-legacy/src/main/res/values/themes.xml diff --git a/android/festago/app/src/main/res/xml/activity_ticket_history_scene.xml b/android/festago/presentation-legacy/src/main/res/xml/activity_ticket_history_scene.xml similarity index 100% rename from android/festago/app/src/main/res/xml/activity_ticket_history_scene.xml rename to android/festago/presentation-legacy/src/main/res/xml/activity_ticket_history_scene.xml diff --git a/android/festago/app/src/main/res/xml/backup_rules.xml b/android/festago/presentation-legacy/src/main/res/xml/backup_rules.xml similarity index 100% rename from android/festago/app/src/main/res/xml/backup_rules.xml rename to android/festago/presentation-legacy/src/main/res/xml/backup_rules.xml diff --git a/android/festago/app/src/main/res/xml/data_extraction_rules.xml b/android/festago/presentation-legacy/src/main/res/xml/data_extraction_rules.xml similarity index 100% rename from android/festago/app/src/main/res/xml/data_extraction_rules.xml rename to android/festago/presentation-legacy/src/main/res/xml/data_extraction_rules.xml diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/fixture/TicketFixture.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/fixture/TicketFixture.kt similarity index 100% rename from android/festago/app/src/test/java/com/festago/festago/presentation/fixture/TicketFixture.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/fixture/TicketFixture.kt diff --git a/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/rule/MainDispatcherRule.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/rule/MainDispatcherRule.kt new file mode 100644 index 000000000..3059da632 --- /dev/null +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/rule/MainDispatcherRule.kt @@ -0,0 +1,28 @@ +package com.festago.festago.presentation.rule + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +// Reusable JUnit4 TestRule to override the Main dispatcher +class MainDispatcherRule +@OptIn(ExperimentalCoroutinesApi::class) +constructor( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + + @OptIn(ExperimentalCoroutinesApi::class) + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/HomeViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/HomeViewModelTest.kt similarity index 89% rename from android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/HomeViewModelTest.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/HomeViewModelTest.kt index 6a7c5616c..b867fe986 100644 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/HomeViewModelTest.kt +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/HomeViewModelTest.kt @@ -1,38 +1,30 @@ package com.festago.festago.presentation.ui.home import app.cash.turbine.test +import com.festago.festago.presentation.rule.MainDispatcherRule import com.festago.festago.repository.AuthRepository import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test class HomeViewModelTest { + private lateinit var vm: HomeViewModel private lateinit var authRepository: AuthRepository - @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + @Before fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) authRepository = mockk() vm = HomeViewModel(authRepository) } - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun finish() { - Dispatchers.resetMain() - } - private fun `사용자 인증 유무가 다음과 같을 때`(isSigned: Boolean) { every { authRepository.isSigned } returns isSigned } diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModelTest.kt similarity index 81% rename from android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModelTest.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModelTest.kt index 56b5b060b..83681a632 100644 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModelTest.kt +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModelTest.kt @@ -1,22 +1,19 @@ package com.festago.festago.presentation.ui.home.festivallist import app.cash.turbine.test -import com.festago.festago.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.AnalyticsHelper import com.festago.festago.model.Festival +import com.festago.festago.model.FestivalFilter +import com.festago.festago.presentation.rule.MainDispatcherRule import com.festago.festago.repository.FestivalRepository import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.SoftAssertions -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import java.time.LocalDate @@ -36,36 +33,31 @@ class FestivalListViewModelTest { ) } - @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + @Before fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) festivalRepository = mockk() analyticsHelper = mockk(relaxed = true) vm = FestivalListViewModel(festivalRepository, analyticsHelper) } - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun finish() { - Dispatchers.resetMain() - } - private fun `축제 목록 요청 결과가 다음과 같을 때`(result: Result>) { coEvery { - festivalRepository.loadFestivals() + festivalRepository.loadFestivals(any()) } answers { result } } @Test - fun `축제 목록 받아오기에 성공하면 성공 상태이고 축제 목록을 반환한다`() { + fun `진행 예정인 축제 목록 받아오기에 성공하면 성공 상태이고 축제 목록을 반환한다`() { // given `축제 목록 요청 결과가 다음과 같을 때`(Result.success(fakeFestivals)) // when - vm.loadFestivals() + vm.loadFestivals(FestivalFilter.PLANNED) // then val softly = SoftAssertions().apply { @@ -85,12 +77,12 @@ class FestivalListViewModelTest { } @Test - fun `축제 목록 받아오기에 실패하면 에러 상태다`() { + fun `진행 예정 축제 목록 받아오기에 실패하면 에러 상태이다`() { // given `축제 목록 요청 결과가 다음과 같을 때`(Result.failure(Exception())) // when - vm.loadFestivals() + vm.loadFestivals(FestivalFilter.PLANNED) // then val softly = SoftAssertions().apply { @@ -108,14 +100,14 @@ class FestivalListViewModelTest { fun `축제 목록을 받아오는 중이면 로딩 상태다`() { // given coEvery { - festivalRepository.loadFestivals() + festivalRepository.loadFestivals(any()) } coAnswers { delay(1000) Result.success(emptyList()) } // when - vm.loadFestivals() + vm.loadFestivals(FestivalFilter.PLANNED) // then val softly = SoftAssertions().apply { @@ -131,7 +123,6 @@ class FestivalListViewModelTest { @Test fun `티켓 예매를 열면 티켓 예매 열기 이벤트가 발생한다`() = runTest { - vm.event.test { // when val fakeFestivalId = 1L diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModelTest.kt similarity index 94% rename from android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModelTest.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModelTest.kt index 2eb87bdae..4cad701b8 100644 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModelTest.kt +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModelTest.kt @@ -1,32 +1,29 @@ package com.festago.festago.presentation.ui.home.mypage import app.cash.turbine.test -import com.festago.festago.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.AnalyticsHelper import com.festago.festago.model.MemberTicketFestival import com.festago.festago.model.Stage import com.festago.festago.model.Ticket import com.festago.festago.model.TicketCondition import com.festago.festago.model.UserProfile +import com.festago.festago.presentation.rule.MainDispatcherRule import com.festago.festago.repository.AuthRepository import com.festago.festago.repository.TicketRepository import com.festago.festago.repository.UserRepository import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.SoftAssertions -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import java.time.LocalDateTime class MyPageViewModelTest { + private lateinit var vm: MyPageViewModel private lateinit var userRepository: UserRepository private lateinit var ticketRepository: TicketRepository @@ -55,10 +52,11 @@ class MyPageViewModelTest { ), ) - @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + @Before fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) userRepository = mockk(relaxed = true) ticketRepository = mockk() authRepository = mockk() @@ -66,12 +64,6 @@ class MyPageViewModelTest { vm = MyPageViewModel(userRepository, ticketRepository, authRepository, analyticsHelper) } - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun finish() { - Dispatchers.resetMain() - } - private fun `로그인 상태가 다음과 같다`(result: Boolean) { coEvery { authRepository.isSigned diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModelTest.kt similarity index 91% rename from android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModelTest.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModelTest.kt index 050fe50fd..2b309ba8e 100644 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModelTest.kt +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListViewModelTest.kt @@ -1,44 +1,36 @@ package com.festago.festago.presentation.ui.home.ticketlist import app.cash.turbine.test -import com.festago.festago.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.AnalyticsHelper import com.festago.festago.model.Ticket import com.festago.festago.presentation.fixture.TicketFixture +import com.festago.festago.presentation.rule.MainDispatcherRule import com.festago.festago.repository.TicketRepository import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.assertj.core.api.SoftAssertions -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test class TicketListViewModelTest { + private lateinit var vm: TicketListViewModel private lateinit var ticketRepository: TicketRepository private lateinit var analyticsHelper: AnalyticsHelper - @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + @Before fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) ticketRepository = mockk() analyticsHelper = mockk(relaxed = true) vm = TicketListViewModel(ticketRepository, analyticsHelper) } - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun finish() { - Dispatchers.resetMain() - } - private fun `현재 티켓 요청 결과가 다음과 같을 때`(result: Result>) { coEvery { ticketRepository.loadCurrentTickets() } returns result } diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModelTest.kt similarity index 88% rename from android/festago/app/src/test/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModelTest.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModelTest.kt index d6708dc56..86ecc2796 100644 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModelTest.kt +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/selectschool/SelectSchoolViewModelTest.kt @@ -1,34 +1,30 @@ package com.festago.festago.presentation.ui.selectschool import app.cash.turbine.test -import com.festago.festago.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.AnalyticsHelper import com.festago.festago.model.School +import com.festago.festago.presentation.rule.MainDispatcherRule import com.festago.festago.repository.SchoolRepository import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.SoftAssertions.assertSoftly -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) class SelectSchoolViewModelTest { - private val testDispatcher = UnconfinedTestDispatcher() private lateinit var vm: SelectSchoolViewModel private lateinit var schoolRepository: SchoolRepository private lateinit var analyticsHelper: AnalyticsHelper + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + @Before fun setup() { - Dispatchers.setMain(testDispatcher) schoolRepository = mockk() analyticsHelper = mockk(relaxed = true) vm = SelectSchoolViewModel( @@ -37,11 +33,6 @@ class SelectSchoolViewModelTest { ) } - @After - fun finish() { - Dispatchers.resetMain() - } - private fun `학교 목록 불러오기 요청 결과가 다음과 같을 때 `(result: Result>) { coEvery { schoolRepository.loadSchools() diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/signin/SignInViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/signin/SignInViewModelTest.kt similarity index 75% rename from android/festago/app/src/test/java/com/festago/festago/presentation/ui/signin/SignInViewModelTest.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/signin/SignInViewModelTest.kt index c8f209389..aab821c90 100644 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/signin/SignInViewModelTest.kt +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/signin/SignInViewModelTest.kt @@ -1,42 +1,33 @@ package com.festago.festago.presentation.ui.signin import app.cash.turbine.test -import com.festago.festago.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.presentation.rule.MainDispatcherRule import com.festago.festago.repository.AuthRepository import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test class SignInViewModelTest { + private lateinit var vm: SignInViewModel private lateinit var authRepository: AuthRepository private lateinit var analyticsHelper: AnalyticsHelper - @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + @Before fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) - authRepository = mockk(relaxed = true) analyticsHelper = mockk(relaxed = true) vm = SignInViewModel(authRepository, analyticsHelper) } - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun finish() { - Dispatchers.resetMain() - } - private fun `로그인 결과가 다음과 같을 때`(result: Result) { coEvery { authRepository.signIn() } returns result } diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModelTest.kt similarity index 94% rename from android/festago/app/src/test/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModelTest.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModelTest.kt index 672066619..9fd56ab87 100644 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModelTest.kt +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/studentverification/StudentVerificationViewModelTest.kt @@ -1,36 +1,32 @@ package com.festago.festago.presentation.ui.studentverification import app.cash.turbine.test -import com.festago.festago.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.AnalyticsHelper import com.festago.festago.model.StudentVerificationCode +import com.festago.festago.presentation.rule.MainDispatcherRule import com.festago.festago.repository.SchoolRepository import com.festago.festago.repository.StudentVerificationRepository import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.SoftAssertions.assertSoftly -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) class StudentVerificationViewModelTest { - private val testDispatcher = UnconfinedTestDispatcher() private lateinit var vm: StudentVerificationViewModel private lateinit var studentVerificationRepository: StudentVerificationRepository private lateinit var schoolRepository: SchoolRepository private lateinit var analyticsHelper: AnalyticsHelper + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + @Before fun setUp() { - Dispatchers.setMain(testDispatcher) studentVerificationRepository = mockk() schoolRepository = mockk() analyticsHelper = mockk(relaxed = true) @@ -41,12 +37,6 @@ class StudentVerificationViewModelTest { ) } - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun finish() { - Dispatchers.resetMain() - } - private fun `이메일 요청 결과가 다음과 같을 때`(result: Result) { coEvery { schoolRepository.loadSchoolEmail(any()) diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModelTest.kt similarity index 92% rename from android/festago/app/src/test/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModelTest.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModelTest.kt index 9369a2a87..8398da7ed 100644 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModelTest.kt +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/ticketentry/TicketEntryViewModelTest.kt @@ -1,43 +1,35 @@ package com.festago.festago.presentation.ui.ticketentry -import com.festago.festago.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.AnalyticsHelper import com.festago.festago.model.Ticket import com.festago.festago.model.TicketCode import com.festago.festago.presentation.fixture.TicketFixture +import com.festago.festago.presentation.rule.MainDispatcherRule import com.festago.festago.repository.TicketRepository import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.assertj.core.api.SoftAssertions -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test class TicketEntryViewModelTest { + private lateinit var vm: TicketEntryViewModel private lateinit var ticketRepository: TicketRepository private lateinit var analyticsHelper: AnalyticsHelper - @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + @Before fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) ticketRepository = mockk() analyticsHelper = mockk(relaxed = true) vm = TicketEntryViewModel(ticketRepository, analyticsHelper) } - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun finish() { - Dispatchers.resetMain() - } - private fun `티켓 요쳥 결과는 다음과 같을 때`(result: Result) { coEvery { ticketRepository.loadTicket(any()) } returns result } diff --git a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModelTest.kt similarity index 90% rename from android/festago/app/src/test/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModelTest.kt rename to android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModelTest.kt index b9c8f8fec..7cdf3f2f5 100644 --- a/android/festago/app/src/test/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModelTest.kt +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/tickethistory/TicketHistoryViewModelTest.kt @@ -1,32 +1,29 @@ package com.festago.festago.presentation.ui.tickethistory -import com.festago.festago.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.AnalyticsHelper import com.festago.festago.model.Ticket import com.festago.festago.presentation.fixture.TicketFixture +import com.festago.festago.presentation.rule.MainDispatcherRule import com.festago.festago.repository.TicketRepository import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain import org.assertj.core.api.SoftAssertions -import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test class TicketHistoryViewModelTest { - private lateinit var vm: TicketHistoryViewModel + private lateinit var vm: TicketHistoryViewModel private lateinit var ticketRepository: TicketRepository private lateinit var analyticsHelper: AnalyticsHelper - @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + @Before fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) ticketRepository = mockk() analyticsHelper = mockk(relaxed = true) @@ -36,12 +33,6 @@ class TicketHistoryViewModelTest { ) } - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun finish() { - Dispatchers.resetMain() - } - private fun `티켓 기록 요청 결과가 다음과 같을 때`(result: Result>) { coEvery { ticketRepository.loadHistoryTickets(any()) diff --git a/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModelTest.kt b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModelTest.kt new file mode 100644 index 000000000..852e3f9fb --- /dev/null +++ b/android/festago/presentation-legacy/src/test/java/com/festago/festago/presentation/ui/ticketreserve/TicketReserveViewModelTest.kt @@ -0,0 +1,280 @@ +package com.festago.festago.presentation.ui.ticketreserve + +import app.cash.turbine.test +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.model.ErrorCode +import com.festago.festago.model.Reservation +import com.festago.festago.model.ReservationStage +import com.festago.festago.model.ReservationTicket +import com.festago.festago.model.ReservationTickets +import com.festago.festago.model.ReservedTicket +import com.festago.festago.model.TicketType +import com.festago.festago.presentation.rule.MainDispatcherRule +import com.festago.festago.repository.AuthRepository +import com.festago.festago.repository.FestivalRepository +import com.festago.festago.repository.ReservationTicketRepository +import com.festago.festago.repository.TicketRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.AssertionsForClassTypes.assertThat +import org.assertj.core.api.SoftAssertions +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime + +class TicketReserveViewModelTest { + + private lateinit var vm: TicketReserveViewModel + private lateinit var reservationTicketRepository: ReservationTicketRepository + private lateinit var festivalRepository: FestivalRepository + private lateinit var ticketRepository: TicketRepository + private lateinit var authRepository: AuthRepository + private lateinit var analyticsHelper: AnalyticsHelper + + private val fakeReservationTickets = ReservationTickets( + listOf( + ReservationTicket(1, TicketType.STUDENT, 219, 500), + ReservationTicket(1, TicketType.VISITOR, 212, 300), + ), + ) + private val fakeReservationStage = ReservationStage( + id = 1, + lineUp = "르세라핌, 아이브, 뉴진스", + reservationTickets = fakeReservationTickets, + startTime = LocalDateTime.now(), + ticketOpenTime = LocalDateTime.now(), + ) + private val fakeReservationStages = List(5) { fakeReservationStage } + private val fakeReservation = Reservation( + id = 1, + name = "테코대학교", + reservationStages = fakeReservationStages, + startDate = LocalDate.now(), + endDate = LocalDate.now(), + thumbnail = "https://search2.kakaocdn.net/argon/656x0_80_wr/8vLywd3V06c", + ) + + private val fakeReservedTicket = ReservedTicket( + id = 1, + entryTime = LocalDateTime.now(), + number = 1, + ) + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Before + fun setUp() { + reservationTicketRepository = mockk() + festivalRepository = mockk() + ticketRepository = mockk() + authRepository = mockk() + analyticsHelper = mockk(relaxed = true) + vm = TicketReserveViewModel( + reservationTicketRepository, + festivalRepository, + ticketRepository, + authRepository, + analyticsHelper, + ) + } + + private fun `예약 정보 요청 결과가 다음과 같을 때`(result: Result) { + coEvery { festivalRepository.loadFestivalDetail(any()) } returns result + } + + private fun `인증 여부가 다음과 같을 때`(isSigned: Boolean) { + coEvery { authRepository.isSigned } answers { isSigned } + } + + private fun `특정 공연의 티켓 타입 요청 결과가 다음과 같을 때`(result: Result) { + coEvery { reservationTicketRepository.loadTicketTypes(any()) } returns result + } + + private fun `티켓 예약 요청 결과가 다음과 같을 때`(result: Result) { + coEvery { ticketRepository.reserveTicket(any()) } returns result + } + + @Test + fun `예약 정보를 불러오면 성공 이벤트가 발생하고 리스트를 반환한다`() { + // given + `예약 정보 요청 결과가 다음과 같을 때`(Result.success(fakeReservation)) + `인증 여부가 다음과 같을 때`(true) + + // when + vm.loadReservation() + + // then + assertThat(vm.uiState.value).isInstanceOf(TicketReserveUiState.Success::class.java) + + // and + val festival = (vm.uiState.value as TicketReserveUiState.Success).festival + val expected = ReservationFestivalUiState( + id = festival.id, + name = festival.name, + thumbnail = festival.thumbnail, + endDate = festival.endDate, + startDate = festival.startDate, + ) + assertThat(festival).isEqualTo(expected) + } + + @Test + fun `예약 정보를 불러오는 것을 실패하면 에러 이벤트가 발생한다`() { + // given + `예약 정보 요청 결과가 다음과 같을 때`(Result.failure(Exception())) + + // when + vm.loadReservation(0) + + // then + assertThat(vm.uiState.value).isEqualTo(TicketReserveUiState.Error) + } + + @Test + fun `예약 정보를 불러오는 중이면 로딩 이벤트가 발생한다`() { + // given + coEvery { + festivalRepository.loadFestivalDetail(0) + } coAnswers { + delay(1000) + Result.success(fakeReservation) + } + + // when + vm.loadReservation() + + // then + assertThat(vm.uiState.value).isEqualTo(TicketReserveUiState.Loading) + } + + @Test + fun `특정 공연의 티켓 타입을 보여주는 이벤트가 발생하면 해당 공연의 티켓 타입을 보여준다`() = runTest { + // given + `특정 공연의 티켓 타입 요청 결과가 다음과 같을 때`(Result.success(fakeReservationTickets)) + `인증 여부가 다음과 같을 때`(true) + + vm.event.test { + // when + vm.showTicketTypes(1, LocalDateTime.MIN) + + // then + val softly = SoftAssertions().apply { + val event = awaitItem() + assertThat(event).isExactlyInstanceOf(TicketReserveEvent.ShowTicketTypes::class.java) + + // and + val actual = (event as? TicketReserveEvent.ShowTicketTypes)?.tickets + assertThat(actual).isEqualTo(fakeReservationTickets.sortedByTicketTypes()) + } + softly.assertAll() + } + } + + @Test + fun `특정 공연의 티켓 타입을 보여주는 것을 실패하면 에러 이벤트가 발생한다`() { + // given + `특정 공연의 티켓 타입 요청 결과가 다음과 같을 때`(Result.failure(Exception())) + `인증 여부가 다음과 같을 때`(true) + + // when + vm.showTicketTypes(1, LocalDateTime.MIN) + + // then + assertThat(vm.uiState.value).isEqualTo(TicketReserveUiState.Error) + } + + @Test + fun `티켓 유형을 선택하고 예약하면 예매 성공 이벤트가 발생한다`() = runTest { + // given + coEvery { + ticketRepository.reserveTicket(any()) + } answers { + Result.success(fakeReservedTicket) + } + + vm.event.test { + // when + vm.reserveTicket(0) + + // then + assertThat(awaitItem()).isExactlyInstanceOf(TicketReserveEvent.ReserveTicketSuccess::class.java) + } + } + + @Test + fun `학생 인증하지 않아 티켓 예매에 실패하면 예매 실패 이벤트가 발생한다`() = runTest { + // given + `티켓 예약 요청 결과가 다음과 같을 때`(Result.failure(ErrorCode.NEED_STUDENT_VERIFICATION())) + + vm.event.test { + // when + vm.reserveTicket(0) + + // then + val actual = awaitItem() as? TicketReserveEvent.ReserveTicketFailed + assertThat(actual).isNotNull + + // and: 학생 인증 필요 예매 실패 코드를 가진다 + assertThat(actual?.errorCode).isExactlyInstanceOf(ErrorCode.NEED_STUDENT_VERIFICATION::class.java) + } + } + + @Test + fun `이미 예매한 티켓이라서 티켓 예매에 실패하면 예매 실패 이벤트가 발생한다`() = runTest { + // given + `티켓 예약 요청 결과가 다음과 같을 때`(Result.failure(ErrorCode.RESERVE_TICKET_OVER_AMOUNT())) + + vm.event.test { + // when + vm.reserveTicket(0) + + // then + val actual = awaitItem() as? TicketReserveEvent.ReserveTicketFailed + assertThat(actual).isNotNull + + // and: 보유 가능한 수량 초과 예매 실패 코드를 가진다 + assertThat(actual?.errorCode).isExactlyInstanceOf(ErrorCode.RESERVE_TICKET_OVER_AMOUNT::class.java) + } + } + + @Test + fun `티켓이 매진되어 티켓 예매에 실패하면 예매 실패 이벤트가 발생한다`() = runTest { + // given + `티켓 예약 요청 결과가 다음과 같을 때`(Result.failure(ErrorCode.TICKET_SOLD_OUT())) + + vm.event.test { + // when + vm.reserveTicket(0) + + // then + val actual = awaitItem() as? TicketReserveEvent.ReserveTicketFailed + assertThat(actual).isNotNull + + // and: 티켓 매진 예매 실패 코드를 가진다 + assertThat(actual?.errorCode).isExactlyInstanceOf(ErrorCode.TICKET_SOLD_OUT::class.java) + } + } + + @Test + fun `알 수 없는 오류로 티켓 예매에 실패하면 예매 실패 이벤트가 발생한다`() = runTest { + // given + `티켓 예약 요청 결과가 다음과 같을 때`(Result.failure(Exception())) + + vm.event.test { + // when + vm.reserveTicket(0) + + // then + val actual = awaitItem() as? TicketReserveEvent.ReserveTicketFailed + assertThat(actual).isNotNull + + // and: 알 수 없는 예매 실패 코드를 가진다 + assertThat(actual?.errorCode).isExactlyInstanceOf(ErrorCode.UNKNOWN::class.java) + } + } +} diff --git a/android/festago/presentation/.gitignore b/android/festago/presentation/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/android/festago/presentation/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/festago/presentation/build.gradle.kts b/android/festago/presentation/build.gradle.kts new file mode 100644 index 000000000..05e1d77f8 --- /dev/null +++ b/android/festago/presentation/build.gradle.kts @@ -0,0 +1,149 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("kotlin-parcelize") + id("kotlin-kapt") + id("org.jlleitschuh.gradle.ktlint") + id("com.google.dagger.hilt.android") + id("androidx.navigation.safeargs") +} + +android { + namespace = "com.festago.festago.presentation" + compileSdk = 34 + + defaultConfig { + minSdk = 28 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getSecretKey("kakao_native_app_key")) + resValue("string", "kakao_redirection_scheme", getSecretKey("kakao_redirection_scheme")) + } + + buildTypes { + debug { + buildConfigField("Boolean", "DEBUG_MODE", "true") + } + + release { + isMinifyEnabled = false + buildConfigField("Boolean", "DEBUG_MODE", "false") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + dataBinding { + enable = true + } +} + +tasks.withType().all { + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(project(":common")) + implementation(project(":domain")) + + // Feature module Support + implementation("androidx.navigation:navigation-dynamic-features-fragment:2.7.7") + implementation("androidx.navigation:navigation-ui-ktx:2.7.7") + implementation("com.google.firebase:firebase-config-ktx:21.6.3") + + // Testing Navigation + androidTestImplementation("androidx.navigation:navigation-testing:2.7.7") + // android + implementation("androidx.core:core-ktx:1.10.1") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.9.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + + // hilt + implementation("com.google.dagger:hilt-android:2.44") + kapt("com.google.dagger:hilt-android-compiler:2.44") + // hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } + implementation("com.google.dagger:hilt-android-testing:2.44") + + // recyclerview + implementation("androidx.recyclerview:recyclerview:1.3.1-rc01") + + // lifecycle + implementation("androidx.lifecycle:lifecycle-extensions:2.2.0") + + // glide + implementation("com.github.bumptech.glide:glide:4.15.1") + + // glide blur + implementation("jp.wasabeef:glide-transformations:4.3.0") + + // retrofit + implementation("com.squareup.retrofit2:retrofit:2.9.0") + + // junit4 + testImplementation("junit:junit:4.13.2") + testImplementation("androidx.test.ext:junit:1.1.5") + testImplementation("androidx.test:runner:1.5.2") + + // assertJ + testImplementation("org.assertj:assertj-core:3.22.0") + + // android-test + testImplementation("androidx.arch.core:core-testing:2.2.0") + + // mock + testImplementation("io.mockk:mockk-android:1.13.5") + + // espresso + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + + // coroutine + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") + + // viewModel + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") + implementation("androidx.activity:activity-ktx:1.7.2") + implementation("androidx.fragment:fragment-ktx:1.6.0") + + // zxing + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + + // firebase + implementation("com.google.firebase:firebase-messaging-ktx:23.4.0") + + // swiperefreshlayout + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + + // kakao login + implementation("com.kakao.sdk:v2-user:2.12.0") + + // turbine + testImplementation("app.cash.turbine:turbine:1.0.0") + + // inApp Update + implementation("com.google.android.play:app-update-ktx:2.1.0") + + // splash + implementation("androidx.core:core-splashscreen:1.1.0-alpha02") +} + +fun getSecretKey(propertyKey: String): String { + return gradleLocalProperties(rootDir).getProperty(propertyKey) +} diff --git a/android/festago/presentation/consumer-rules.pro b/android/festago/presentation/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/android/festago/presentation/proguard-rules.pro b/android/festago/presentation/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/android/festago/presentation/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/festago/presentation/src/androidTest/java/com/festago/festago/.gitkeep b/android/festago/presentation/src/androidTest/java/com/festago/festago/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/festago/presentation/src/main/AndroidManifest.xml b/android/festago/presentation/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5ab54f712 --- /dev/null +++ b/android/festago/presentation/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailArgs.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailArgs.kt new file mode 100644 index 000000000..539fe2b23 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailArgs.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.artistdetail + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ArtistDetailArgs(val id: Long, val name: String, val profileUrl: String) : Parcelable diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailEvent.kt new file mode 100644 index 000000000..fd4b61819 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailEvent.kt @@ -0,0 +1,11 @@ +package com.festago.festago.presentation.ui.artistdetail + +import com.festago.festago.presentation.ui.artistdetail.uistate.ArtistUiState +import com.festago.festago.presentation.ui.artistdetail.uistate.FestivalItemUiState + +sealed interface ArtistDetailEvent { + class ShowArtistDetail(val artist: ArtistUiState) : ArtistDetailEvent + class ShowFestivalDetail(val festival: FestivalItemUiState) : ArtistDetailEvent + class BookmarkSuccess(val isBookmarked: Boolean) : ArtistDetailEvent + class BookmarkFailure(val message: String) : ArtistDetailEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailFragment.kt new file mode 100644 index 000000000..ee3af37b7 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailFragment.kt @@ -0,0 +1,177 @@ +package com.festago.festago.presentation.ui.artistdetail + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.festago.festago.domain.model.social.SocialMediaType +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.FragmentArtistDetailBinding +import com.festago.festago.presentation.databinding.ItemMediaBinding +import com.festago.festago.presentation.ui.artistdetail.adapter.festival.ArtistDetailAdapter +import com.festago.festago.presentation.ui.artistdetail.uistate.ArtistDetailUiState +import com.festago.festago.presentation.ui.artistdetail.uistate.MoreItemUiState +import com.festago.festago.presentation.ui.bindingadapter.setImage +import com.festago.festago.presentation.ui.festivaldetail.FestivalDetailArgs +import com.festago.festago.presentation.util.repeatOnStarted +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ArtistDetailFragment : Fragment() { + private var _binding: FragmentArtistDetailBinding? = null + private val binding get() = _binding!! + + private val vm: ArtistDetailViewModel by viewModels() + + private val args: ArtistDetailFragmentArgs by navArgs() + + private val adapter = ArtistDetailAdapter() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentArtistDetailBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initView() + initObserve() + } + + private fun initView() { + loadArtistDetail() + binding.rvToDoList.adapter = adapter + initButton() + } + + private fun loadArtistDetail() { + binding.tvArtistName.text = args.artist.name + binding.ivProfileImage.setImage(args.artist.profileUrl) + val delayTimeMillis = resources.getInteger(R.integer.nav_Anim_time).toLong() + vm.loadArtistDetail(args.artist.id, delayTimeMillis) + } + + private fun initButton() { + binding.ivBack.setOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + binding.cvBookmark.isSelected + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { + binding.uiState = it + updateUi(it) + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { + handleEvent(it) + } + } + } + + private fun updateUi(uiState: ArtistDetailUiState) = when (uiState) { + is ArtistDetailUiState.Loading -> Unit + is ArtistDetailUiState.Success -> handleSuccess(uiState) + is ArtistDetailUiState.Error -> handleError(uiState) + } + + private fun handleSuccess(uiState: ArtistDetailUiState.Success) { + binding.successUiState = uiState + binding.tvArtistName.text = uiState.artist.artistName + binding.ivProfileImage.setImage(uiState.artist.profileUrl) + + binding.ivBookmark.isSelected = uiState.bookMarked + + val items: List = if (uiState.isLast) { + uiState.festivals + } else { + uiState.festivals + MoreItemUiState { vm.loadMoreArtistFestivals(args.artist.id) } + } + adapter.submitList(items) + + binding.llcArtistMedia.removeAllViews() + + uiState.artist.artistMedia.map { media -> + with(ItemMediaBinding.inflate(layoutInflater, binding.llcArtistMedia, false)) { + findMediaRes(media.type) + ivImage.setOnClickListener { startBrowser(media.url) } + binding.llcArtistMedia.addView(ivImage) + } + } + } + + private fun findMediaRes(type: SocialMediaType): Int { + return when (type) { + SocialMediaType.YOUTUBE -> R.drawable.ic_youtube + SocialMediaType.INSTAGRAM -> R.drawable.ic_instagram + SocialMediaType.X -> R.drawable.ic_x + SocialMediaType.FACEBOOK -> R.drawable.ic_facebook + SocialMediaType.NONE -> R.drawable.bg_festago_default + } + } + + private fun handleError(uiState: ArtistDetailUiState.Error) { + binding.refreshListener = { uiState.refresh(args.artist.id) } + } + + private fun handleEvent(event: ArtistDetailEvent) = when (event) { + is ArtistDetailEvent.ShowArtistDetail -> { + findNavController().navigate( + ArtistDetailFragmentDirections.actionArtistDetailFragmentSelf( + with(event.artist) { ArtistDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is ArtistDetailEvent.ShowFestivalDetail -> { + findNavController().navigate( + ArtistDetailFragmentDirections.actionArtistDetailFragmentToFestivalDetailFragment( + with(event.festival) { FestivalDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is ArtistDetailEvent.BookmarkSuccess -> { + Toast.makeText( + requireContext(), + if (event.isBookmarked) { + getString(R.string.artist_detail_bookmark_success) + } else { + getString(R.string.artist_detail_bookmark_cancel) + }, + Toast.LENGTH_SHORT, + ).show() + } + + is ArtistDetailEvent.BookmarkFailure -> { + Toast.makeText(requireContext(), event.message, Toast.LENGTH_SHORT) + .show() + } + } + + private fun startBrowser(url: String) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailViewModel.kt new file mode 100644 index 000000000..e19709cf7 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/ArtistDetailViewModel.kt @@ -0,0 +1,200 @@ +package com.festago.festago.presentation.ui.artistdetail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.domain.exception.isBookmarkLimitExceeded +import com.festago.festago.domain.exception.isNetworkError +import com.festago.festago.domain.exception.isUnauthorized +import com.festago.festago.domain.model.bookmark.BookmarkType +import com.festago.festago.domain.model.festival.FestivalsPage +import com.festago.festago.domain.repository.ArtistRepository +import com.festago.festago.domain.repository.BookmarkRepository +import com.festago.festago.domain.repository.UserRepository +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailEvent.BookmarkFailure +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailEvent.BookmarkSuccess +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailEvent.ShowArtistDetail +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailEvent.ShowFestivalDetail +import com.festago.festago.presentation.ui.artistdetail.uistate.ArtistDetailUiState +import com.festago.festago.presentation.ui.artistdetail.uistate.ArtistUiState +import com.festago.festago.presentation.ui.artistdetail.uistate.FestivalItemUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class ArtistDetailViewModel @Inject constructor( + private val artistRepository: ArtistRepository, + private val bookmarkRepository: BookmarkRepository, + private val userRepository: UserRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + private val _event: MutableSharedFlow = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + private val _uiState: MutableStateFlow = + MutableStateFlow(ArtistDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadArtistDetail(id: Long, delayTimeMillis: Long, refresh: Boolean = false) { + if (!refresh && _uiState.value is ArtistDetailUiState.Success) return + + viewModelScope.launch { + runCatching { + val deferredArtistDetail = + async { artistRepository.loadArtistDetail(id, delayTimeMillis) } + val deferredFestivals = async { + artistRepository.loadArtistFestivals(id = id, size = FESTIVAL_PAGE_SIZE) + } + + val artist = deferredArtistDetail.await().getOrThrow() + val festivalPage = deferredFestivals.await().getOrThrow() + + val isBookmarked = if (userRepository.isSigned()) { + bookmarkRepository.isBookmarked(id, BookmarkType.ARTIST) + } else { + false + } + + _uiState.value = ArtistDetailUiState.Success( + artist = artist, + bookMarked = isBookmarked, + festivals = festivalPage.toUiState(), + festivalPage.isLastPage, + onBookmarkClick = ::toggleArtistBookmark, + ) + + if (!festivalPage.isLastPage) { + return@launch + } + val lastFestival = festivalPage.festivals.lastOrNull() + if (lastFestival == null || lastFestival.endDate >= LocalDate.now()) { + loadMoreArtistFestivals(id) + return@launch + } + }.onFailure { + handleFailure(key = KEY_LOAD_ARTIST_DETAIL, throwable = it) + } + } + } + + fun loadMoreArtistFestivals(artistId: Long) { + val successUiState = uiState.value as? ArtistDetailUiState.Success ?: return + + viewModelScope.launch { + val currentFestivals = successUiState.festivals + val lastItem = currentFestivals.lastOrNull() + val isPast = + lastItem?.endDate?.isBefore(LocalDate.now()) ?: true || successUiState.isLast + + val lastFestivalId = if (successUiState.isLast) null else lastItem?.id + val lastStartDate = if (successUiState.isLast) null else lastItem?.startDate + + artistRepository.loadArtistFestivals( + id = artistId, + lastFestivalId = lastFestivalId, + lastStartDate = lastStartDate, + isPast = isPast, + size = FESTIVAL_PAGE_SIZE, + ).onSuccess { festivalsPage -> + _uiState.value = successUiState.copy( + festivals = currentFestivals + festivalsPage.toUiState(), + isLast = festivalsPage.isLastPage, + ) + }.onFailure { + handleFailure(key = KEY_LOAD_MORE_ARTIST_FESTIVAL, throwable = it) + } + } + } + + private fun toggleArtistBookmark(artistId: Int) { + viewModelScope.launch { + val uiState = uiState.value as? ArtistDetailUiState.Success ?: return@launch + + if (uiState.bookMarked) { + _uiState.value = uiState.copy(bookMarked = false) + bookmarkRepository.deleteArtistBookmark(artistId.toLong()) + .onSuccess { _event.emit(BookmarkSuccess(false)) } + .onFailure { + if (it.isUnauthorized()) { + _event.emit(BookmarkFailure("로그인이 필요해요")) + } + if (it.isNetworkError()) { + _uiState.value = uiState.copy(bookMarked = true) + _event.emit(BookmarkFailure("인터넷 연결을 확인해주세요")) + } + } + } else { + _uiState.value = uiState.copy(bookMarked = true) + bookmarkRepository.addArtistBookmark(artistId.toLong()) + .onSuccess { _event.emit(BookmarkSuccess(true)) } + .onFailure { + if (it.isUnauthorized()) { + _event.emit(BookmarkFailure("로그인이 필요해요")) + } + if (it.isBookmarkLimitExceeded()) { + _uiState.value = uiState.copy(bookMarked = false) + _event.emit(BookmarkFailure("북마크는 12개까지 가능해요")) + } + if (it.isNetworkError()) { + _uiState.value = uiState.copy(bookMarked = false) + _event.emit(BookmarkFailure("인터넷 연결을 확인해주세요")) + } + } + } + } + } + + private fun handleFailure(key: String, throwable: Throwable) { + _uiState.value = ArtistDetailUiState.Error { + _uiState.value = ArtistDetailUiState.Loading + loadArtistDetail(it, 0L) + } + analyticsHelper.logNetworkFailure( + key = key, + value = throwable.message.toString(), + ) + } + + private fun FestivalsPage.toUiState() = festivals.map { + FestivalItemUiState( + id = it.id, + name = it.name, + imageUrl = it.imageUrl, + startDate = it.startDate, + endDate = it.endDate, + artists = it.artists.map { artist -> + ArtistUiState( + id = artist.id, + name = artist.name, + imageUrl = artist.imageUrl, + onArtistDetailClick = { artistDetail -> + viewModelScope.launch { + _event.emit(ShowArtistDetail(artistDetail)) + } + }, + ) + }, + onFestivalDetailClick = { festivalDetail -> + viewModelScope.launch { + _event.emit(ShowFestivalDetail(festivalDetail)) + } + }, + ) + } + + companion object { + private const val FESTIVAL_PAGE_SIZE = 20 + private const val KEY_LOAD_ARTIST_DETAIL = "KEY_LOAD_ARTIST_DETAIL" + private const val KEY_LOAD_MORE_ARTIST_FESTIVAL = "KEY_LOAD_MORE_ARTIST_FESTIVAL" + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/artistlist/ArtistAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/artistlist/ArtistAdapter.kt new file mode 100644 index 000000000..500c3e577 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/artistlist/ArtistAdapter.kt @@ -0,0 +1,36 @@ +package com.festago.festago.presentation.ui.artistdetail.adapter.artistlist + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.artistdetail.uistate.ArtistUiState + +class ArtistAdapter() : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder { + return ArtistViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil by lazy { + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem == newItem + } + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/artistlist/ArtistViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/artistlist/ArtistViewHolder.kt new file mode 100644 index 000000000..5fb7bb970 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/artistlist/ArtistViewHolder.kt @@ -0,0 +1,25 @@ +package com.festago.festago.presentation.ui.artistdetail.adapter.artistlist + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemArtistDetailArtistBinding +import com.festago.festago.presentation.ui.artistdetail.uistate.ArtistUiState + +class ArtistViewHolder(private val binding: ItemArtistDetailArtistBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(item: ArtistUiState) { + binding.artist = item + } + + companion object { + fun of(parent: ViewGroup): ArtistViewHolder { + val binding = ItemArtistDetailArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailAdapter.kt new file mode 100644 index 000000000..d5dde54dd --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailAdapter.kt @@ -0,0 +1,52 @@ +package com.festago.festago.presentation.ui.artistdetail.adapter.festival + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.artistdetail.uistate.FestivalItemUiState +import com.festago.festago.presentation.ui.artistdetail.uistate.MoreItemUiState + +class ArtistDetailAdapter : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistDetailViewHolder { + return when (viewType) { + 1 -> ArtistDetailFestivalViewHolder.of(parent) + 2 -> ArtistDetailMoreItemViewHolder.of(parent) + else -> throw IllegalArgumentException("Invalid viewType") + } + } + + override fun onBindViewHolder(holder: ArtistDetailViewHolder, position: Int) { + val item = getItem(position) + return when (holder) { + is ArtistDetailFestivalViewHolder -> holder.bind(item as FestivalItemUiState) + is ArtistDetailMoreItemViewHolder -> holder.bind(item as MoreItemUiState) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is FestivalItemUiState -> 1 + is MoreItemUiState -> 2 + else -> throw IllegalArgumentException("Invalid Item") + } + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean = when { + oldItem is FestivalItemUiState && newItem is FestivalItemUiState -> oldItem.id == newItem.id + oldItem is MoreItemUiState && newItem is MoreItemUiState -> true + else -> false + } + + override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean = when { + oldItem is FestivalItemUiState && newItem is FestivalItemUiState + -> oldItem as FestivalItemUiState == newItem + + oldItem is MoreItemUiState && newItem is MoreItemUiState -> true + + else -> false + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailFestivalViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailFestivalViewHolder.kt new file mode 100644 index 000000000..55867a9cb --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailFestivalViewHolder.kt @@ -0,0 +1,103 @@ +package com.festago.festago.presentation.ui.artistdetail.adapter.festival + +import android.content.res.Resources +import android.graphics.Rect +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemArtistDetailFestivalBinding +import com.festago.festago.presentation.ui.artistdetail.adapter.artistlist.ArtistAdapter +import com.festago.festago.presentation.ui.artistdetail.uistate.FestivalItemUiState +import java.time.LocalDate + +class ArtistDetailFestivalViewHolder( + private val binding: ItemArtistDetailFestivalBinding, +) : ArtistDetailViewHolder(binding) { + private val artistAdapter = ArtistAdapter() + + init { + binding.rvFestivalArtists.adapter = artistAdapter + binding.rvFestivalArtists.addItemDecoration(ArtistItemDecoration()) + binding.rvFestivalArtists.itemAnimator = null + } + + fun bind(item: FestivalItemUiState) { + binding.item = item + artistAdapter.submitList(item.artists) + binding.tvEmptyStage.visibility = if (item.artists.isEmpty()) View.VISIBLE else View.GONE + bindDDayView(item) + } + + private fun bindDDayView(item: FestivalItemUiState) { + val context = binding.root.context + + when { + LocalDate.now() in item.startDate..item.endDate -> { + binding.tvFestivalDDay.text = + context.getString(R.string.tv_dday_in_progress) + binding.tvFestivalDDay.setTextColor(context.getColor(R.color.secondary_pink_01)) + binding.tvFestivalDDay.background = AppCompatResources.getDrawable( + context, + R.drawable.bg_festival_list_dday_in_progress, + ) + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + } + + LocalDate.now() < item.startDate -> { + val dDay = LocalDate.now().toEpochDay() - item.startDate.toEpochDay() + val backgroundColor = if (dDay >= -7L) { + context.getColor(R.color.secondary_pink_01) + } else { + context.getColor(R.color.contents_gray_07) + } + binding.tvFestivalDDay.setBackgroundColor(backgroundColor) + binding.tvFestivalDDay.setTextColor(context.getColor(R.color.background_gray_01)) + binding.tvFestivalDDay.text = + context.getString(R.string.tv_dday_format, dDay.toString()) + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + } + + else -> { + binding.tvFestivalDDay.visibility = View.GONE + binding.tvFestivalDDayEnd.visibility = View.VISIBLE + } + } + } + + private class ArtistItemDecoration : ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + outRect.right = 8.dpToPx + } + + private val Int.dpToPx: Int + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ).toInt() + } + + companion object { + fun of(parent: ViewGroup): ArtistDetailFestivalViewHolder { + val binding = ItemArtistDetailFestivalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistDetailFestivalViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailMoreItemViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailMoreItemViewHolder.kt new file mode 100644 index 000000000..c068d680b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailMoreItemViewHolder.kt @@ -0,0 +1,25 @@ +package com.festago.festago.presentation.ui.artistdetail.adapter.festival + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemArtistDetailMoreItemBinding +import com.festago.festago.presentation.ui.artistdetail.uistate.MoreItemUiState + +class ArtistDetailMoreItemViewHolder( + binding: ItemArtistDetailMoreItemBinding +) : ArtistDetailViewHolder(binding) { + fun bind(item: MoreItemUiState) { + item.requestMore() + } + + companion object { + fun of(parent: ViewGroup): ArtistDetailMoreItemViewHolder { + val binding = ItemArtistDetailMoreItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistDetailMoreItemViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailViewHolder.kt new file mode 100644 index 000000000..c2dbc2ed7 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/adapter/festival/ArtistDetailViewHolder.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.artistdetail.adapter.festival + +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView + +sealed class ArtistDetailViewHolder( + binding: ViewDataBinding +) : RecyclerView.ViewHolder(binding.root) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/ArtistDetailUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/ArtistDetailUiState.kt new file mode 100644 index 000000000..3f1199fdc --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/ArtistDetailUiState.kt @@ -0,0 +1,22 @@ +package com.festago.festago.presentation.ui.artistdetail.uistate + +import com.festago.festago.domain.model.artist.ArtistDetail + +sealed interface ArtistDetailUiState { + object Loading : ArtistDetailUiState + + data class Success( + val artist: ArtistDetail, + val bookMarked: Boolean, + val festivals: List, + val isLast: Boolean, + val onBookmarkClick: (Int) -> Unit, + ) : ArtistDetailUiState + + class Error(val refresh: (id: Long) -> Unit) : ArtistDetailUiState + + val shouldShowSuccess get() = this is Success + val shouldShowEmptyFestivals get() = this is Success && festivals.isEmpty() + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/ArtistUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/ArtistUiState.kt new file mode 100644 index 000000000..d30e64c49 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/ArtistUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.artistdetail.uistate + +data class ArtistUiState( + val id: Long, + val name: String, + val imageUrl: String, + val onArtistDetailClick: (ArtistUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/FestivalItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/FestivalItemUiState.kt new file mode 100644 index 000000000..76f4c7280 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/FestivalItemUiState.kt @@ -0,0 +1,13 @@ +package com.festago.festago.presentation.ui.artistdetail.uistate + +import java.time.LocalDate + +data class FestivalItemUiState( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val imageUrl: String, + val artists: List, + val onFestivalDetailClick: (FestivalItemUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/MoreItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/MoreItemUiState.kt new file mode 100644 index 000000000..cc0d8491c --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/artistdetail/uistate/MoreItemUiState.kt @@ -0,0 +1,3 @@ +package com.festago.festago.presentation.ui.artistdetail.uistate + +data class MoreItemUiState(val requestMore: () -> Unit) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/bindingadapter/Background.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/bindingadapter/Background.kt new file mode 100644 index 000000000..53c851842 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/bindingadapter/Background.kt @@ -0,0 +1,9 @@ +package com.festago.festago.presentation.ui.bindingadapter + +import android.view.View +import androidx.databinding.BindingAdapter + +@BindingAdapter("background") +fun View.setBackground(resourceId: Int) { + setBackgroundResource(resourceId) +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/bindingadapter/Image.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/bindingadapter/Image.kt new file mode 100644 index 000000000..6c37a371e --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/bindingadapter/Image.kt @@ -0,0 +1,43 @@ +package com.festago.festago.presentation.ui.bindingadapter + +import android.view.ViewOutlineProvider +import android.widget.ImageView +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.festago.festago.presentation.R +import jp.wasabeef.glide.transformations.BlurTransformation + +@BindingAdapter("imageUrl") +fun ImageView.setImage(imageUrl: String?) { + Glide.with(context) + .load(imageUrl) + .placeholder(R.drawable.bg_festago_default) + .error(R.drawable.bg_festago_default) + .fallback(R.drawable.bg_festago_default) + .into(this) +} + +@BindingAdapter("imageUrl", "blurRadius", "blurSampling") +fun ImageView.setBlurImage(imageUrl: String?, blurRadius: Int, blurSampling: Int) { + val transformation = BlurTransformation(blurRadius, blurSampling) + Glide.with(context) + .load(imageUrl) + .placeholder(R.drawable.bg_festago_default) + .apply(RequestOptions.bitmapTransform(transformation)) + .error(R.drawable.bg_festago_default) + .fallback(R.drawable.bg_festago_default) + .into(this) +} + +@BindingAdapter("elevatedImageUrl") +fun ImageView.setElevatedImage(imageUrl: String?) { + Glide.with(context) + .load(imageUrl) + .placeholder(R.drawable.bg_festago_default) + .error(R.drawable.bg_festago_default) + .fallback(R.drawable.bg_festago_default) + .into(this) + this.elevation = 12f + this.outlineProvider = ViewOutlineProvider.BOUNDS +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/bindingadapter/Visibility.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/bindingadapter/Visibility.kt new file mode 100644 index 000000000..652d62b98 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/bindingadapter/Visibility.kt @@ -0,0 +1,22 @@ +package com.festago.festago.presentation.ui.bindingadapter + +import android.view.View +import androidx.constraintlayout.motion.widget.MotionLayout +import androidx.core.view.doOnLayout +import androidx.databinding.BindingAdapter + +@BindingAdapter("visibility") +fun View.setVisibility(isVisible: Boolean) { + this.visibility = if (isVisible) View.VISIBLE else View.GONE +} + +@BindingAdapter("visibilityOnMotionLayout") +fun View.setVisibilityOnMotionLayout(visible: Boolean) { + val layout = this.parent as MotionLayout + doOnLayout { + this.visibility = if (visible) View.VISIBLE else View.GONE + } + layout.constraintSetIds.forEach { + layout.getConstraintSet(it).setVisibility(this.id, if (visible) View.VISIBLE else View.GONE) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/ClearEditText.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/ClearEditText.kt new file mode 100644 index 000000000..2df1be761 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/ClearEditText.kt @@ -0,0 +1,92 @@ +package com.festago.festago.presentation.ui.customview + +import android.content.Context +import android.graphics.drawable.Drawable +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.View.OnFocusChangeListener +import android.view.View.OnTouchListener +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.widget.AppCompatEditText +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import com.festago.festago.presentation.R + +class ClearEditText(context: Context, attrs: AttributeSet) : + AppCompatEditText(context, attrs), + TextWatcher, + OnTouchListener, + OnFocusChangeListener { + + private val clearDrawable: Drawable by lazy { + val drawable = ContextCompat.getDrawable(context, R.drawable.ic_circle_close)!! + DrawableCompat.wrap(drawable) + } + private var onFocusChangeListener: OnFocusChangeListener? = null + private var onTouchListener: OnTouchListener? = null + + init { + clearDrawable.setBounds( + 0, + 0, + clearDrawable.intrinsicWidth, + clearDrawable.intrinsicHeight, + ) + setClearIconVisible(false) + super.setOnTouchListener(this) + super.setOnFocusChangeListener(this) + addTextChangedListener(this) + } + + override fun setOnFocusChangeListener(onFocusChangeListener: OnFocusChangeListener) { + this.onFocusChangeListener = onFocusChangeListener + } + + override fun setOnTouchListener(onTouchListener: OnTouchListener) { + this.onTouchListener = onTouchListener + } + + override fun onFocusChange(view: View, hasFocus: Boolean) { + setClearIconVisible(text!!.isNotEmpty()) + if (onFocusChangeListener != null) { + onFocusChangeListener!!.onFocusChange(view, hasFocus) + } + } + + override fun onTouch(view: View, motionEvent: MotionEvent): Boolean { + val x = motionEvent.x.toInt() + if (clearDrawable.isVisible && x > width - paddingRight - clearDrawable.intrinsicWidth) { + if (motionEvent.action == MotionEvent.ACTION_UP) { + error = null + text = null + isFocusableInTouchMode = true + val inputMethodManager = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + requestFocus() + inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } + return true + } + return if (onTouchListener != null) { + onTouchListener!!.onTouch(view, motionEvent) + } else { + false + } + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + if (!isFocused) return + setClearIconVisible(s.isNotEmpty()) + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + + override fun afterTextChanged(s: Editable) = Unit + + private fun setClearIconVisible(visible: Boolean) { + clearDrawable.setVisible(visible, false) + setCompoundDrawables(null, null, if (visible) clearDrawable else null, null) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/FestagoButton.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/FestagoButton.kt new file mode 100644 index 000000000..af1318081 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/FestagoButton.kt @@ -0,0 +1,27 @@ +package com.festago.festago.presentation.ui.customview + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.BtnFestagoBinding + +class FestagoButton(context: Context, attrs: AttributeSet) : + ConstraintLayout(context, attrs) { + + private val binding by lazy { + BtnFestagoBinding.inflate(LayoutInflater.from(context), this, true) + } + + init { + initAttrs(attrs) + } + + private fun initAttrs(attrs: AttributeSet) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.FestagoButton) + val title = typedArray.getString(R.styleable.FestagoButton_title) + binding.tvFestagoBtn.text = title + typedArray.recycle() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/OrientationAwareRecyclerView.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/OrientationAwareRecyclerView.kt new file mode 100644 index 000000000..6cadf13f8 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/customview/OrientationAwareRecyclerView.kt @@ -0,0 +1,59 @@ +package com.festago.festago.presentation.ui.customview + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.recyclerview.widget.RecyclerView + +/** + * A RecyclerView that only handles scroll events with the same orientation of its LayoutManager. + * Avoids situations where nested recyclerviews don't receive touch events properly: + */ +class OrientationAwareRecyclerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet, + defStyleAttr: Int = 0, +) : RecyclerView(context, attrs, defStyleAttr) { + + private var lastX = 0.0f + private var lastY = 0.0f + private var scrolling = false + + init { + addOnScrollListener(object : OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + scrolling = newState != SCROLL_STATE_IDLE + } + }) + } + + override fun onInterceptTouchEvent(e: MotionEvent): Boolean { + val lm = layoutManager ?: return super.onInterceptTouchEvent(e) + var allowScroll = true + when (e.actionMasked) { + MotionEvent.ACTION_DOWN -> { + lastX = e.x + lastY = e.y + // If we were scrolling, stop now by faking a touch release + if (scrolling) { + val newEvent = MotionEvent.obtain(e) + newEvent.action = MotionEvent.ACTION_UP + return super.onInterceptTouchEvent(newEvent) + } + } + + MotionEvent.ACTION_MOVE -> { + // We're moving, so check if we're trying + // to scroll vertically or horizontally so we don't intercept the wrong event. + val currentX = e.x + val currentY = e.y + val dx = Math.abs(currentX - lastX) + val dy = Math.abs(currentY - lastY) + allowScroll = if (dy > dx) lm.canScrollVertically() else lm.canScrollHorizontally() + } + } + if (!allowScroll) return false + return super.onInterceptTouchEvent(e) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailArgs.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailArgs.kt new file mode 100644 index 000000000..5790df930 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailArgs.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.festivaldetail + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FestivalDetailArgs(val id: Long, val name: String, val posterImageUrl: String) : + Parcelable diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailEvent.kt new file mode 100644 index 000000000..aa0d8c93e --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailEvent.kt @@ -0,0 +1,11 @@ +package com.festago.festago.presentation.ui.festivaldetail + +import com.festago.festago.domain.model.school.School +import com.festago.festago.presentation.ui.festivaldetail.uiState.ArtistItemUiState + +sealed interface FestivalDetailEvent { + class ShowArtistDetail(val artist: ArtistItemUiState) : FestivalDetailEvent + class ShowSchoolDetail(val school: School) : FestivalDetailEvent + class BookmarkSuccess(val isBookmarked: Boolean) : FestivalDetailEvent + class BookmarkFailure(val message: String) : FestivalDetailEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailFragment.kt new file mode 100644 index 000000000..9e9e34af9 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailFragment.kt @@ -0,0 +1,218 @@ +package com.festago.festago.presentation.ui.festivaldetail + +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.content.res.AppCompatResources +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.festago.festago.domain.model.social.SocialMedia +import com.festago.festago.domain.model.social.SocialMediaType +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.FragmentFestivalDetailBinding +import com.festago.festago.presentation.databinding.ItemMediaBinding +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailArgs +import com.festago.festago.presentation.ui.bindingadapter.setImage +import com.festago.festago.presentation.ui.festivaldetail.adapter.stage.StageListAdapter +import com.festago.festago.presentation.ui.festivaldetail.uiState.FestivalDetailUiState +import com.festago.festago.presentation.ui.schooldetail.SchoolDetailArgs +import com.festago.festago.presentation.util.repeatOnStarted +import dagger.hilt.android.AndroidEntryPoint +import java.time.LocalDate + +@AndroidEntryPoint +class FestivalDetailFragment : Fragment() { + private var _binding: FragmentFestivalDetailBinding? = null + private val binding get() = _binding!! + + private val vm: FestivalDetailViewModel by viewModels() + + private val args: FestivalDetailFragmentArgs by navArgs() + + private lateinit var adapter: StageListAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentFestivalDetailBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initView() + initObserve() + } + + private fun initView() { + loadFestivalDetail() + initStageAdapter() + initButton() + } + + private fun loadFestivalDetail() { + binding.ivFestivalPoster.setImage(args.festival.posterImageUrl) + binding.ivFestivalBackground.setImage(args.festival.posterImageUrl) + binding.tvFestivalName.text = args.festival.name + val delayTimeMillis = resources.getInteger(R.integer.nav_Anim_time).toLong() + vm.loadFestivalDetail(args.festival.id, delayTimeMillis) + } + + private fun initStageAdapter() { + adapter = StageListAdapter() + binding.rvStageList.adapter = adapter + } + + private fun initButton() { + binding.ivBack.setOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { + binding.uiState = it + updateUi(it) + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { + handleEvent(it) + } + } + } + + private fun updateUi(uiState: FestivalDetailUiState) { + when (uiState) { + is FestivalDetailUiState.Loading -> Unit + is FestivalDetailUiState.Success -> handleSuccess(uiState) + is FestivalDetailUiState.Error -> handleError(uiState) + } + } + + private fun handleSuccess(uiState: FestivalDetailUiState.Success) { + binding.successUiState = uiState + binding.ivBookmark.isSelected = uiState.bookmarked + binding.tvFestivalDDay.setFestivalDDay(uiState.festival.startDate, uiState.festival.endDate) + binding.ivFestivalPoster.setImage(uiState.festival.posterImageUrl) + binding.ivFestivalBackground.setImage(uiState.festival.posterImageUrl) + binding.tvFestivalName.text = uiState.festival.name + adapter.submitList(uiState.stages) + binding.llcFestivalSocialMedia.removeAllViews() + uiState.festival.socialMedias.forEach { media -> + with(ItemMediaBinding.inflate(layoutInflater, binding.llcFestivalSocialMedia, false)) { + ivImage.setImageResource(findMediaRes(media)) + ivImage.setOnClickListener { startBrowser(media.url) } + binding.llcFestivalSocialMedia.addView(ivImage) + } + } + } + + private fun findMediaRes(media: SocialMedia): Int { + val res = when (media.type) { + SocialMediaType.INSTAGRAM -> R.drawable.ic_instagram + SocialMediaType.FACEBOOK -> R.drawable.ic_facebook + SocialMediaType.YOUTUBE -> R.drawable.ic_youtube + SocialMediaType.X -> R.drawable.ic_x + else -> R.drawable.bg_festago_default + } + return res + } + + private fun handleError(uiState: FestivalDetailUiState.Error) { + binding.refreshListener = { uiState.refresh(args.festival.id) } + } + + private fun TextView.setFestivalDDay(startDate: LocalDate, endDate: LocalDate) { + when { + LocalDate.now() in startDate..endDate -> { + text = context.getString(R.string.tv_dday_in_progress) + setTextColor(context.getColor(R.color.secondary_pink_01)) + background = AppCompatResources.getDrawable( + context, + R.drawable.bg_festival_list_dday_in_progress, + ) + } + + LocalDate.now() < startDate -> { + val dDay = LocalDate.now().toEpochDay() - startDate.toEpochDay() + val backgroundColor = if (dDay >= -7L) { + context.getColor(R.color.secondary_pink_01) + } else { + context.getColor(R.color.contents_gray_07) + } + setBackgroundColor(backgroundColor) + setTextColor(context.getColor(R.color.background_gray_01)) + text = context.getString(R.string.tv_dday_format, dDay.toString()) + } + + else -> { + setBackgroundColor(Color.TRANSPARENT) + setTextColor(context.getColor(R.color.background_gray_01)) + background = AppCompatResources.getDrawable( + context, + R.drawable.bg_festival_detail_dday_end, + ) + text = context.getString(R.string.tv_dday_end) + } + } + } + + private fun startBrowser(url: String) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + + private fun handleEvent(event: FestivalDetailEvent) { + when (event) { + is FestivalDetailEvent.ShowArtistDetail -> { + findNavController().navigate( + FestivalDetailFragmentDirections.actionFestivalDetailFragmentToArtistDetailFragment( + with(event.artist) { ArtistDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is FestivalDetailEvent.ShowSchoolDetail -> { + findNavController().navigate( + FestivalDetailFragmentDirections.actionFestivalDetailFragmentToSchoolDetailFragment( + with(event.school) { SchoolDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is FestivalDetailEvent.BookmarkSuccess -> { + Toast.makeText( + requireContext(), + if (event.isBookmarked) { + getString(R.string.festival_detail_bookmark_success) + } else { + getString(R.string.festival_detail_bookmark_cancel) + }, + Toast.LENGTH_SHORT, + ).show() + } + + is FestivalDetailEvent.BookmarkFailure -> { + Toast.makeText(requireContext(), event.message, Toast.LENGTH_SHORT).show() + } + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailViewModel.kt new file mode 100644 index 000000000..7ed86340a --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/FestivalDetailViewModel.kt @@ -0,0 +1,164 @@ +package com.festago.festago.presentation.ui.festivaldetail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.domain.exception.isBookmarkLimitExceeded +import com.festago.festago.domain.exception.isNetworkError +import com.festago.festago.domain.exception.isUnauthorized +import com.festago.festago.domain.model.artist.Artist +import com.festago.festago.domain.model.bookmark.BookmarkType +import com.festago.festago.domain.model.festival.FestivalDetail +import com.festago.festago.domain.model.school.School +import com.festago.festago.domain.model.stage.Stage +import com.festago.festago.domain.repository.BookmarkRepository +import com.festago.festago.domain.repository.FestivalRepository +import com.festago.festago.domain.repository.UserRepository +import com.festago.festago.presentation.ui.festivaldetail.FestivalDetailEvent.BookmarkFailure +import com.festago.festago.presentation.ui.festivaldetail.FestivalDetailEvent.BookmarkSuccess +import com.festago.festago.presentation.ui.festivaldetail.FestivalDetailEvent.ShowArtistDetail +import com.festago.festago.presentation.ui.festivaldetail.FestivalDetailEvent.ShowSchoolDetail +import com.festago.festago.presentation.ui.festivaldetail.uiState.ArtistItemUiState +import com.festago.festago.presentation.ui.festivaldetail.uiState.FestivalDetailUiState +import com.festago.festago.presentation.ui.festivaldetail.uiState.FestivalUiState +import com.festago.festago.presentation.ui.festivaldetail.uiState.StageItemUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FestivalDetailViewModel @Inject constructor( + private val festivalRepository: FestivalRepository, + private val bookmarkRepository: BookmarkRepository, + private val userRepository: UserRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FestivalDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun loadFestivalDetail(festivalId: Long, delayTimeMillis: Long, refresh: Boolean = false) { + if (!refresh && _uiState.value is FestivalDetailUiState.Success) return + + viewModelScope.launch { + val deferredFestivalDetail = + async { festivalRepository.loadFestivalDetail(festivalId, delayTimeMillis) } + + runCatching { + val festivalDetail = deferredFestivalDetail.await().getOrThrow() + + val isBookmarked = if (userRepository.isSigned()) { + bookmarkRepository.isBookmarked(festivalId, BookmarkType.FESTIVAL) + } else { + false + } + + _uiState.value = festivalDetail.toSuccessUiState(isBookmarked) + }.onFailure { + _uiState.value = FestivalDetailUiState.Error { festivalId -> + _uiState.value = FestivalDetailUiState.Loading + loadFestivalDetail(festivalId, 0L) + } + analyticsHelper.logNetworkFailure( + key = KEY_LOAD_FESTIVAL_DETAIL, + value = it.message.toString(), + ) + } + } + } + + private fun FestivalDetail.toSuccessUiState(isBookmarked: Boolean) = + FestivalDetailUiState.Success( + festival = FestivalUiState( + id = id, + name = name, + startDate = startDate, + endDate = endDate, + posterImageUrl = posterImageUrl, + school = school, + onSchoolClick = ::showSchoolDetail, + socialMedias = socialMedias, + ), + bookmarked = isBookmarked, + stages = stages.map { it.toUiState() }, + onBookmarkClick = { festivalId -> toggleFestivalBookmark(festivalId) }, + ) + + private fun toggleFestivalBookmark(festivalId: Long) { + viewModelScope.launch { + val uiState = _uiState.value as? FestivalDetailUiState.Success ?: return@launch + + if (uiState.bookmarked) { + _uiState.value = uiState.copy(bookmarked = false) + bookmarkRepository.deleteFestivalBookmark(festivalId) + .onSuccess { _event.emit(BookmarkSuccess(false)) } + .onFailure { + if (it.isUnauthorized()) { + _event.emit(BookmarkFailure("로그인이 필요해요")) + } + if (it.isNetworkError()) { + _uiState.value = uiState.copy(bookmarked = true) + _event.emit(BookmarkFailure("인터넷 연결을 확인해주세요")) + } + } + } else { + _uiState.value = uiState.copy(bookmarked = true) + bookmarkRepository.addFestivalBookmark(festivalId) + .onSuccess { _event.emit(BookmarkSuccess(true)) } + .onFailure { + if (it.isUnauthorized()) { + _event.emit(BookmarkFailure("로그인이 필요해요")) + } + if (it.isBookmarkLimitExceeded()) { + _uiState.value = uiState.copy(bookmarked = false) + _event.emit(BookmarkFailure("북마크는 12개까지 가능해요")) + } + if (it.isNetworkError()) { + _uiState.value = uiState.copy(bookmarked = false) + _event.emit(BookmarkFailure("인터넷 연결을 확인해주세요")) + } + } + } + } + } + + private fun Stage.toUiState() = StageItemUiState( + id = id, + startDateTime = startDateTime, + artists = artists.map { it.toUiState() }, + ) + + private fun Artist.toUiState() = ArtistItemUiState( + id = id, + name = name, + imageUrl = imageUrl, + onArtistDetail = ::showArtistDetail, + ) + + private fun showArtistDetail(artist: ArtistItemUiState) { + viewModelScope.launch { + _event.emit(ShowArtistDetail(artist)) + } + } + + private fun showSchoolDetail(school: School) { + viewModelScope.launch { + _event.emit(ShowSchoolDetail(school)) + } + } + + companion object { + private const val KEY_LOAD_FESTIVAL_DETAIL = "KEY_LOAD_FESTIVAL_DETAIL" + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/artist/ArtistAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/artist/ArtistAdapter.kt new file mode 100644 index 000000000..aa465f746 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/artist/ArtistAdapter.kt @@ -0,0 +1,35 @@ +package com.festago.festago.presentation.ui.festivaldetail.adapter.artist + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.festivaldetail.uiState.ArtistItemUiState + +class ArtistAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder { + return ArtistViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistItemUiState, + newItem: ArtistItemUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ArtistItemUiState, + newItem: ArtistItemUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/artist/ArtistViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/artist/ArtistViewHolder.kt new file mode 100644 index 000000000..b023f23e7 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/artist/ArtistViewHolder.kt @@ -0,0 +1,27 @@ +package com.festago.festago.presentation.ui.festivaldetail.adapter.artist + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemFestivalDetailStageArtistBinding +import com.festago.festago.presentation.ui.festivaldetail.uiState.ArtistItemUiState + +class ArtistViewHolder( + private val binding: ItemFestivalDetailStageArtistBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ArtistItemUiState) { + binding.item = item + } + + companion object { + fun of(parent: ViewGroup): ArtistViewHolder { + val binding = ItemFestivalDetailStageArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/stage/StageListAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/stage/StageListAdapter.kt new file mode 100644 index 000000000..99491ada9 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/stage/StageListAdapter.kt @@ -0,0 +1,35 @@ +package com.festago.festago.presentation.ui.festivaldetail.adapter.stage + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.festivaldetail.uiState.StageItemUiState + +class StageListAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StageViewHolder { + return StageViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: StageViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: StageItemUiState, + newItem: StageItemUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: StageItemUiState, + newItem: StageItemUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/stage/StageViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/stage/StageViewHolder.kt new file mode 100644 index 000000000..c24844964 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/adapter/stage/StageViewHolder.kt @@ -0,0 +1,36 @@ +package com.festago.festago.presentation.ui.festivaldetail.adapter.stage + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemFestivalDetailStageBinding +import com.festago.festago.presentation.ui.festivaldetail.adapter.artist.ArtistAdapter +import com.festago.festago.presentation.ui.festivaldetail.uiState.StageItemUiState + +class StageViewHolder( + private val binding: ItemFestivalDetailStageBinding, +) : RecyclerView.ViewHolder(binding.root) { + + private val artistAdapter = ArtistAdapter() + + init { + binding.rvStageArtists.adapter = artistAdapter + binding.rvStageArtists.itemAnimator = null + } + + fun bind(item: StageItemUiState) { + binding.item = item + artistAdapter.submitList(item.artists) + } + + companion object { + fun of(parent: ViewGroup): StageViewHolder { + val binding = ItemFestivalDetailStageBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return StageViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/ArtistItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/ArtistItemUiState.kt new file mode 100644 index 000000000..fd8dc7b27 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/ArtistItemUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.festivaldetail.uiState + +data class ArtistItemUiState( + val id: Long, + val name: String, + val imageUrl: String, + val onArtistDetail: (artist: ArtistItemUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/FestivalDetailUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/FestivalDetailUiState.kt new file mode 100644 index 000000000..7ec8e78d7 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/FestivalDetailUiState.kt @@ -0,0 +1,19 @@ +package com.festago.festago.presentation.ui.festivaldetail.uiState + +interface FestivalDetailUiState { + object Loading : FestivalDetailUiState + + data class Success( + val festival: FestivalUiState, + val bookmarked: Boolean, + val stages: List, + val onBookmarkClick: (Long) -> Unit, + ) : FestivalDetailUiState + + class Error(val refresh: (id: Long) -> Unit) : FestivalDetailUiState + + val shouldShowSuccess get() = this is Success + val shouldShowEmptyStages get() = shouldShowSuccess && (this as Success).stages.isEmpty() + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/FestivalUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/FestivalUiState.kt new file mode 100644 index 000000000..58722f69b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/FestivalUiState.kt @@ -0,0 +1,16 @@ +package com.festago.festago.presentation.ui.festivaldetail.uiState + +import com.festago.festago.domain.model.school.School +import com.festago.festago.domain.model.social.SocialMedia +import java.time.LocalDate + +data class FestivalUiState( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val posterImageUrl: String, + val school: School, + val socialMedias: List, + val onSchoolClick: (School) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/StageItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/StageItemUiState.kt new file mode 100644 index 000000000..e5ea66194 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/festivaldetail/uiState/StageItemUiState.kt @@ -0,0 +1,9 @@ +package com.festago.festago.presentation.ui.festivaldetail.uiState + +import java.time.LocalDateTime + +data class StageItemUiState( + val id: Long, + val startDateTime: LocalDateTime, + val artists: List, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeActivity.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeActivity.kt new file mode 100644 index 000000000..3992245cf --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeActivity.kt @@ -0,0 +1,129 @@ +package com.festago.festago.presentation.ui.home + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.addCallback +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.navigation.NavController +import androidx.navigation.NavOptions +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ActivityHomeBinding +import com.festago.festago.presentation.util.setOnApplyWindowInsetsCompatListener +import com.festago.festago.presentation.util.setStatusBarMode +import com.google.android.material.bottomnavigation.BottomNavigationView +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class HomeActivity : AppCompatActivity() { + private val binding by lazy { ActivityHomeBinding.inflate(layoutInflater) } + + private val vm: HomeViewModel by viewModels() + + private lateinit var navController: NavController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initBinding() + initView() + initNavigation() + + initBackPressedDispatcher() + initDestinationChangedListener() + } + + private fun initView() { + vm.initBookmark() + binding.root.setOnApplyWindowInsetsCompatListener { view, windowInsets -> + val navigationInsets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) + view.setPadding(0, 0, 0, navigationInsets.bottom) + window.statusBarColor = Color.TRANSPARENT + windowInsets + } + WindowCompat.setDecorFitsSystemWindows(window, false) + binding.nvHome.setOnApplyWindowInsetsCompatListener { _, windowInsets -> + windowInsets + } + } + + private fun initNavigation() { + navController = + (supportFragmentManager.findFragmentById(R.id.fcvHomeContainer) as NavHostFragment).navController + val bottomNavigationView = findViewById(R.id.nvHome) + bottomNavigationView.setupWithNavController(navController) + + setBottomNavPopUpBackstack(bottomNavigationView) + setNavColor() + } + + private fun setBottomNavPopUpBackstack(bottomNavigationView: BottomNavigationView) { + bottomNavigationView.setOnItemSelectedListener { + val options = NavOptions.Builder() + .setPopUpTo(R.id.main_graph_xml, false) + .setLaunchSingleTop(true) + .build() + + navController.navigate(it.itemId, null, options) + true + } + } + + private fun setNavColor() { + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + window.navigationBarColor = ContextCompat.getColor(this, android.R.color.white) + WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars = + true + } + + private fun initBinding() { + setContentView(binding.root) + } + + private fun initDestinationChangedListener() { + navController.addOnDestinationChangedListener { _, destination, _ -> + when (destination.id) { + R.id.artistDetailFragment, + R.id.festivalDetailFragment, + R.id.schoolDetailFragment, + -> setStatusBarMode(isLight = false, backgroundColor = Color.TRANSPARENT) + + else -> setStatusBarMode(isLight = true, backgroundColor = Color.TRANSPARENT) + } + } + } + + private fun initBackPressedDispatcher() { + var backPressedTime = START_BACK_PRESSED_TIME + onBackPressedDispatcher.addCallback { + if ((System.currentTimeMillis() - backPressedTime) > FINISH_BACK_PRESSED_TIME) { + backPressedTime = System.currentTimeMillis() + Toast.makeText( + this@HomeActivity, + getString(R.string.home_back_pressed), + Toast.LENGTH_SHORT, + ).show() + } else { + finish() + } + } + } + + companion object { + private const val START_BACK_PRESSED_TIME = 0L + private const val FINISH_BACK_PRESSED_TIME = 3000L + + fun getIntent(context: Context): Intent { + return Intent(context, HomeActivity::class.java) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt new file mode 100644 index 000000000..0b658f53b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/HomeViewModel.kt @@ -0,0 +1,31 @@ +package com.festago.festago.presentation.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.domain.repository.BookmarkRepository +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val bookmarkRepository: BookmarkRepository, + private val userRepository: UserRepository, +) : ViewModel() { + + fun initBookmark() { + viewModelScope.launch { + if (!userRepository.isSigned()) return@launch + + bookmarkRepository.getArtistBookmarks().onSuccess { + launch { + bookmarkRepository.getSchoolBookmarks() + } + launch { + bookmarkRepository.getFestivalBookmarkIds() + } + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/BookmarkFragmentStateAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/BookmarkFragmentStateAdapter.kt new file mode 100644 index 000000000..20bf400e3 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/BookmarkFragmentStateAdapter.kt @@ -0,0 +1,20 @@ +package com.festago.festago.presentation.ui.home.bookmarklist + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.festago.festago.presentation.ui.home.bookmarklist.artistbookmark.ArtistBookmarkFragment +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.FestivalBookmarkFragment +import com.festago.festago.presentation.ui.home.bookmarklist.schoolbookmark.SchoolBookmarkFragment + +class BookmarkFragmentStateAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { + override fun getItemCount(): Int = 3 + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> FestivalBookmarkFragment() + 1 -> ArtistBookmarkFragment() + 2 -> SchoolBookmarkFragment() + else -> throw IllegalArgumentException("Invalid position") + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/BookmarkListFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/BookmarkListFragment.kt new file mode 100644 index 000000000..3d93628d2 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/BookmarkListFragment.kt @@ -0,0 +1,81 @@ +package com.festago.festago.presentation.ui.home.bookmarklist + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.viewpager2.widget.ViewPager2 +import com.festago.festago.presentation.databinding.FragmentBookmarkListBinding +import com.festago.festago.presentation.util.setOnApplyWindowInsetsCompatListener +import com.google.android.material.tabs.TabLayout +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class BookmarkListFragment : Fragment() { + private var _binding: FragmentBookmarkListBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentBookmarkListBinding.inflate(inflater) + binding.root.setOnApplyWindowInsetsCompatListener { view, windowInsets -> + val statusBarInsets = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + view.setPadding(0, statusBarInsets.top, 0, 0) + windowInsets + } + initView() + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initView() + } + + private fun initView() { + binding.vpBookmarkList.adapter = BookmarkFragmentStateAdapter(this) + + binding.vpBookmarkList.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + when (position) { + 0 -> binding.tlBookmarkListTab.getTabAt(0)?.select() + + 1 -> binding.tlBookmarkListTab.getTabAt(1)?.select() + 2 -> binding.tlBookmarkListTab.getTabAt(2)?.select() + } + } + }, + ) + + binding.tlBookmarkListTab.addOnTabSelectedListener( + object : TabLayout.OnTabSelectedListener { + override fun onTabReselected(tab: TabLayout.Tab?) { + } + + override fun onTabUnselected(tab: TabLayout.Tab?) { + } + + override fun onTabSelected(tab: TabLayout.Tab?) { + when (tab?.position) { + 0 -> binding.vpBookmarkList.currentItem = 0 + 1 -> binding.vpBookmarkList.currentItem = 1 + 2 -> binding.vpBookmarkList.currentItem = 2 + } + } + }, + ) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkAdapter.kt new file mode 100644 index 000000000..ff005a0c2 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkAdapter.kt @@ -0,0 +1,33 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.artistbookmark + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter + +class ArtistBookmarkAdapter : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistBookmarkViewHolder { + return ArtistBookmarkViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistBookmarkViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistBookmarkUiState, + newItem: ArtistBookmarkUiState, + ): Boolean { + return oldItem.name == newItem.name + } + + override fun areContentsTheSame( + oldItem: ArtistBookmarkUiState, + newItem: ArtistBookmarkUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkEvent.kt new file mode 100644 index 000000000..ed3113735 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkEvent.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.artistbookmark + +sealed interface ArtistBookmarkEvent { + class ShowArtistDetail(val artist: ArtistBookmarkUiState) : ArtistBookmarkEvent + object ShowSignIn : ArtistBookmarkEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkFragment.kt new file mode 100644 index 000000000..d1e2033e3 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkFragment.kt @@ -0,0 +1,95 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.artistbookmark + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.festago.festago.presentation.databinding.FragmentArtistBookmarkBinding +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailArgs +import com.festago.festago.presentation.ui.home.bookmarklist.BookmarkListFragmentDirections +import com.festago.festago.presentation.ui.signin.SignInActivity +import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.safeNavigate +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ArtistBookmarkFragment : Fragment() { + private var _binding: FragmentArtistBookmarkBinding? = null + private val binding get() = _binding!! + + private val vm: ArtistBookmarkViewModel by viewModels() + + private lateinit var artistBookmarkAdapter: ArtistBookmarkAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentArtistBookmarkBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initView() + initObserve() + vm.fetchBookmarkList() + } + + private fun initView() { + binding.uiState = vm.uiState.value + + binding.refreshListener = { vm.fetchBookmarkList() } + binding.loginListener = { vm.logIn() } + + artistBookmarkAdapter = ArtistBookmarkAdapter() + binding.rvArtistBookmarkList.adapter = artistBookmarkAdapter + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { uiState -> + binding.uiState = uiState + when (uiState) { + is ArtistBookmarkListUiState.NotLoggedIn, + is ArtistBookmarkListUiState.Loading, + is ArtistBookmarkListUiState.Error, + -> Unit + + is ArtistBookmarkListUiState.Success -> { + artistBookmarkAdapter.submitList(uiState.artistBookmarks) + } + } + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.uiEvent.collect { event -> + when (event) { + is ArtistBookmarkEvent.ShowArtistDetail -> { + findNavController().safeNavigate( + BookmarkListFragmentDirections.actionBookmarkListFragmentToArtistDetailFragment( + with(event.artist) { + ArtistDetailArgs(id, name, imageUrl) + }, + ), + ) + } + + is ArtistBookmarkEvent.ShowSignIn -> { + startActivity(SignInActivity.getIntent(requireContext())) + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkListUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkListUiState.kt new file mode 100644 index 000000000..a3663a85e --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkListUiState.kt @@ -0,0 +1,18 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.artistbookmark + +sealed interface ArtistBookmarkListUiState { + class NotLoggedIn(val logIn: () -> Unit) : ArtistBookmarkListUiState + + object Loading : ArtistBookmarkListUiState + + data class Success(val artistBookmarks: List) : ArtistBookmarkListUiState + + object Error : ArtistBookmarkListUiState + + val shouldShowNotLoggedIn get() = this is NotLoggedIn + + val shouldShowSuccess get() = this is Success && artistBookmarks.isNotEmpty() + val shouldShowEmpty get() = this is Success && artistBookmarks.isEmpty() + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkUiState.kt new file mode 100644 index 000000000..4bc492c03 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.artistbookmark + +data class ArtistBookmarkUiState( + val id: Long, + val name: String, + val imageUrl: String, + val onArtistDetail: (artist: ArtistBookmarkUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkViewHolder.kt new file mode 100644 index 000000000..da06f348f --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkViewHolder.kt @@ -0,0 +1,24 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.artistbookmark + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemArtistBookmarkBinding + +class ArtistBookmarkViewHolder(private val binding: ItemArtistBookmarkBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(item: ArtistBookmarkUiState) { + binding.artist = item + } + + companion object { + fun of(parent: ViewGroup): ArtistBookmarkViewHolder { + val binding = ItemArtistBookmarkBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistBookmarkViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkViewModel.kt new file mode 100644 index 000000000..50a31958b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/artistbookmark/ArtistBookmarkViewModel.kt @@ -0,0 +1,66 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.artistbookmark + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.domain.model.bookmark.ArtistBookmark +import com.festago.festago.domain.repository.BookmarkRepository +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ArtistBookmarkViewModel @Inject constructor( + private val bookmarkRepository: BookmarkRepository, + private val userRepository: UserRepository, +) : ViewModel() { + private val _uiState = + MutableStateFlow(ArtistBookmarkListUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEvent = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() + + fun fetchBookmarkList() { + viewModelScope.launch { + _uiState.value = ArtistBookmarkListUiState.Loading + + if (!userRepository.isSigned()) { + _uiState.value = ArtistBookmarkListUiState.NotLoggedIn(::logIn) + return@launch + } + + bookmarkRepository.getArtistBookmarks().onSuccess { artistBookmarks -> + _uiState.value = ArtistBookmarkListUiState.Success( + artistBookmarks.map { it.toUiState() }, + ) + }.onFailure { + _uiState.value = ArtistBookmarkListUiState.Error + } + } + } + + fun logIn() { + viewModelScope.launch { + _uiEvent.emit(ArtistBookmarkEvent.ShowSignIn) + } + } + + private fun ArtistBookmark.toUiState(): ArtistBookmarkUiState { + return ArtistBookmarkUiState( + id = artist.id, + name = artist.name, + imageUrl = artist.profileImageUrl, + onArtistDetail = { artist -> + viewModelScope.launch { + _uiEvent.emit(ArtistBookmarkEvent.ShowArtistDetail(artist)) + } + }, + ) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/FestivalBookmarkEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/FestivalBookmarkEvent.kt new file mode 100644 index 000000000..294d5f00b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/FestivalBookmarkEvent.kt @@ -0,0 +1,10 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark + +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.artistadapter.ArtistUiState +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.uistate.FestivalBookmarkItemUiState + +sealed interface FestivalBookmarkEvent { + class ShowFestivalDetail(val festival: FestivalBookmarkItemUiState) : FestivalBookmarkEvent + class ShowArtistDetail(val artist: ArtistUiState) : FestivalBookmarkEvent + object ShowSignIn : FestivalBookmarkEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/FestivalBookmarkFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/FestivalBookmarkFragment.kt new file mode 100644 index 000000000..2941d7319 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/FestivalBookmarkFragment.kt @@ -0,0 +1,104 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.festago.festago.presentation.databinding.FragmentFestivalBookmarkBinding +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailArgs +import com.festago.festago.presentation.ui.festivaldetail.FestivalDetailArgs +import com.festago.festago.presentation.ui.home.bookmarklist.BookmarkListFragmentDirections +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.adapater.FestivalBookmarkViewAdapter +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.uistate.FestivalBookmarkUiState +import com.festago.festago.presentation.ui.signin.SignInActivity +import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.safeNavigate +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class FestivalBookmarkFragment : Fragment() { + private var _binding: FragmentFestivalBookmarkBinding? = null + private val binding get() = _binding!! + + private val vm: FestivalBookmarkViewModel by viewModels() + + private lateinit var festivalBookmarkViewAdapter: FestivalBookmarkViewAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentFestivalBookmarkBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initView() + initObserve() + vm.fetchBookmarkList() + } + + private fun initView() { + festivalBookmarkViewAdapter = FestivalBookmarkViewAdapter() + binding.rvFestivalBookmarkList.adapter = festivalBookmarkViewAdapter + + binding.uiState = vm.uiState.value + + binding.refreshListener = { vm.fetchBookmarkList() } + binding.loginListener = { vm.logIn() } + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { uiState -> + binding.uiState = uiState + when (uiState) { + is FestivalBookmarkUiState.NotLoggedIn, + is FestivalBookmarkUiState.Loading, + is FestivalBookmarkUiState.Error, + -> Unit + + is FestivalBookmarkUiState.Success -> + festivalBookmarkViewAdapter.submitList(uiState.festivalBookmarks) + } + } + } + + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { event -> + when (event) { + is FestivalBookmarkEvent.ShowFestivalDetail -> { + findNavController().safeNavigate( + BookmarkListFragmentDirections.actionBookmarkListFragmentToFestivalDetailFragment( + with(event.festival) { FestivalDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is FestivalBookmarkEvent.ShowArtistDetail -> { + findNavController().safeNavigate( + BookmarkListFragmentDirections.actionBookmarkListFragmentToArtistDetailFragment( + with(event.artist) { ArtistDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is FestivalBookmarkEvent.ShowSignIn -> { + startActivity(SignInActivity.getIntent(requireContext())) + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/FestivalBookmarkViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/FestivalBookmarkViewModel.kt new file mode 100644 index 000000000..b6767de37 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/FestivalBookmarkViewModel.kt @@ -0,0 +1,98 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.domain.model.artist.Artist +import com.festago.festago.domain.model.bookmark.FestivalBookmark +import com.festago.festago.domain.model.bookmark.FestivalBookmarkOrder +import com.festago.festago.domain.repository.BookmarkRepository +import com.festago.festago.domain.repository.UserRepository +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.artistadapter.ArtistUiState +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.uistate.FestivalBookmarkItemUiState +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.uistate.FestivalBookmarkUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FestivalBookmarkViewModel @Inject constructor( + private val bookmarkRepository: BookmarkRepository, + private val userRepository: UserRepository, +) : ViewModel() { + private val _uiState = + MutableStateFlow(FestivalBookmarkUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event = MutableSharedFlow() + val event = _event.asSharedFlow() + + fun fetchBookmarkList() { + viewModelScope.launch { + _uiState.value = FestivalBookmarkUiState.Loading + + if (!userRepository.isSigned()) { + _uiState.value = FestivalBookmarkUiState.NotLoggedIn + return@launch + } + + val bookmarkIds = bookmarkRepository.getFestivalBookmarkIds() + .getOrElse { + _uiState.value = FestivalBookmarkUiState.Error + return@launch + } + + if (bookmarkIds.isEmpty()) { + _uiState.value = FestivalBookmarkUiState.Success(emptyList()) + return@launch + } + + bookmarkRepository.getFestivalBookmarks(bookmarkIds, FestivalBookmarkOrder.FESTIVAL) + .onSuccess { festivalBookmarks -> + _uiState.value = + FestivalBookmarkUiState.Success(festivalBookmarks.map { it.toUiState() }) + }.onFailure { + _uiState.value = FestivalBookmarkUiState.Error + } + } + } + + fun logIn() { + viewModelScope.launch { + _event.emit(FestivalBookmarkEvent.ShowSignIn) + } + } + + private fun FestivalBookmark.toUiState(): FestivalBookmarkItemUiState { + return FestivalBookmarkItemUiState( + id = festival.id, + name = festival.name, + imageUrl = festival.imageUrl, + startDate = festival.startDate, + endDate = festival.endDate, + artists = festival.artists.map { it.toUiState() }, + onFestivalDetail = { festival -> + viewModelScope.launch { + _event.emit(FestivalBookmarkEvent.ShowFestivalDetail(festival)) + } + }, + ) + } + + private fun Artist.toUiState(): ArtistUiState { + return ArtistUiState( + id = id, + name = name, + imageUrl = imageUrl, + onArtistDetail = { artist -> + viewModelScope.launch { + _event.emit(FestivalBookmarkEvent.ShowArtistDetail(artist)) + } + }, + ) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/adapater/FestivalBookmarkViewAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/adapater/FestivalBookmarkViewAdapter.kt new file mode 100644 index 000000000..5489c6dc1 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/adapater/FestivalBookmarkViewAdapter.kt @@ -0,0 +1,37 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.adapater + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.uistate.FestivalBookmarkItemUiState + +class FestivalBookmarkViewAdapter : + ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FestivalBookmarkViewHolder { + return FestivalBookmarkViewHolder.of( + parent = parent, + ) + } + + override fun onBindViewHolder(holder: FestivalBookmarkViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: FestivalBookmarkItemUiState, + newItem: FestivalBookmarkItemUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: FestivalBookmarkItemUiState, + newItem: FestivalBookmarkItemUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/adapater/FestivalBookmarkViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/adapater/FestivalBookmarkViewHolder.kt new file mode 100644 index 000000000..40d85ece8 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/adapater/FestivalBookmarkViewHolder.kt @@ -0,0 +1,111 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.adapater + +import android.content.res.Resources +import android.graphics.Rect +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemFestivalBookmarkBinding +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.artistadapter.ArtistAdapter +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.uistate.FestivalBookmarkItemUiState +import java.time.LocalDate + +class FestivalBookmarkViewHolder( + val binding: ItemFestivalBookmarkBinding, +) : + RecyclerView.ViewHolder(binding.root) { + private val artistAdapter = ArtistAdapter() + + init { + binding.rvFestivalArtists.adapter = artistAdapter + binding.rvFestivalArtists.addItemDecoration(ArtistItemDecoration()) + binding.rvFestivalArtists.itemAnimator = null + } + + fun bind(item: FestivalBookmarkItemUiState) { + binding.item = item + artistAdapter.submitList(item.artists) + binding.tvEmptyStage.visibility = if (item.artists.isEmpty()) View.VISIBLE else View.GONE + bindDDayView(item) + } + + private fun bindDDayView(item: FestivalBookmarkItemUiState) { + val context = binding.root.context + + val dDayView = binding.tvFestivalDDay + when { + LocalDate.now() > item.endDate -> { + binding.tvFestivalDDay.visibility = View.GONE + binding.tvFestivalDDayEnd.visibility = View.VISIBLE + } + + LocalDate.now() >= item.startDate -> { + dDayView.text = context.getString(R.string.festival_list_tv_dday_in_progress) + dDayView.setTextColor(context.getColor(R.color.secondary_pink_01)) + dDayView.background = AppCompatResources.getDrawable( + context, + R.drawable.bg_festival_list_dday_in_progress, + ) + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + } + + LocalDate.now() >= item.startDate.minusDays(7) -> { + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + dDayView.setTextColor(context.getColor(R.color.background_gray_01)) + dDayView.text = context.getString( + R.string.festival_list_tv_dday_format, + LocalDate.now().compareTo(item.startDate).toString(), + ) + dDayView.setBackgroundColor(0xffff1273.toInt()) + } + + else -> binding.tvFestivalDDay.apply { + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + dDayView.setTextColor(context.getColor(R.color.background_gray_01)) + dDayView.text = context.getString( + R.string.festival_list_tv_dday_format, + (LocalDate.now().toEpochDay() - item.startDate.toEpochDay()).toString(), + ) + dDayView.setBackgroundColor(context.getColor(android.R.color.black)) + } + } + } + + private class ArtistItemDecoration : ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + outRect.right = 8.dpToPx + } + + private val Int.dpToPx: Int + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ).toInt() + } + + companion object { + fun of(parent: ViewGroup): FestivalBookmarkViewHolder { + val binding = ItemFestivalBookmarkBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalBookmarkViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/artistadapter/ArtistAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/artistadapter/ArtistAdapter.kt new file mode 100644 index 000000000..7cfdb1677 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/artistadapter/ArtistAdapter.kt @@ -0,0 +1,33 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.artistadapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter + +class ArtistAdapter : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder { + return ArtistViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/artistadapter/ArtistUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/artistadapter/ArtistUiState.kt new file mode 100644 index 000000000..f053d3f72 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/artistadapter/ArtistUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.artistadapter + +data class ArtistUiState( + val id: Long, + val name: String, + val imageUrl: String, + val onArtistDetail: (artist: ArtistUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/artistadapter/ArtistViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/artistadapter/ArtistViewHolder.kt new file mode 100644 index 000000000..e753f8625 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/artistadapter/ArtistViewHolder.kt @@ -0,0 +1,24 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.artistadapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemFestivalBookmarkArtistBinding + +class ArtistViewHolder(private val binding: ItemFestivalBookmarkArtistBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(item: ArtistUiState) { + binding.artist = item + } + + companion object { + fun of(parent: ViewGroup): ArtistViewHolder { + val binding = ItemFestivalBookmarkArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/uistate/FestivalBookmarkItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/uistate/FestivalBookmarkItemUiState.kt new file mode 100644 index 000000000..00089c35c --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/uistate/FestivalBookmarkItemUiState.kt @@ -0,0 +1,14 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.uistate + +import com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.artistadapter.ArtistUiState +import java.time.LocalDate + +data class FestivalBookmarkItemUiState( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val imageUrl: String, + val artists: List, + val onFestivalDetail: (festival: FestivalBookmarkItemUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/uistate/FestivalBookmarkUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/uistate/FestivalBookmarkUiState.kt new file mode 100644 index 000000000..7cd9470c5 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/festivalbookmark/uistate/FestivalBookmarkUiState.kt @@ -0,0 +1,18 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.festivalbookmark.uistate + +sealed interface FestivalBookmarkUiState { + object NotLoggedIn : FestivalBookmarkUiState + object Loading : FestivalBookmarkUiState + + data class Success(val festivalBookmarks: List) : + FestivalBookmarkUiState + + object Error : FestivalBookmarkUiState + + val shouldShowNotLoggedIn get() = this is NotLoggedIn + + val shouldShowSuccess get() = this is Success && festivalBookmarks.isNotEmpty() + val shouldShowEmpty get() = this is Success && festivalBookmarks.isEmpty() + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkAdapter.kt new file mode 100644 index 000000000..a88e05b3a --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkAdapter.kt @@ -0,0 +1,33 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.schoolbookmark + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter + +class SchoolBookmarkAdapter : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SchoolBookmarkViewHolder { + return SchoolBookmarkViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: SchoolBookmarkViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: SchoolBookmarkUiState, + newItem: SchoolBookmarkUiState, + ): Boolean { + return oldItem.name == newItem.name + } + + override fun areContentsTheSame( + oldItem: SchoolBookmarkUiState, + newItem: SchoolBookmarkUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkEvent.kt new file mode 100644 index 000000000..2a7dc59e0 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkEvent.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.schoolbookmark + +sealed interface SchoolBookmarkEvent { + class ShowSchoolDetail(val school: SchoolBookmarkUiState) : SchoolBookmarkEvent + object ShowSignIn : SchoolBookmarkEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkFragment.kt new file mode 100644 index 000000000..d8fb5606a --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkFragment.kt @@ -0,0 +1,92 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.schoolbookmark + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.festago.festago.presentation.databinding.FragmentSchoolBookmarkBinding +import com.festago.festago.presentation.ui.home.bookmarklist.BookmarkListFragmentDirections +import com.festago.festago.presentation.ui.schooldetail.SchoolDetailArgs +import com.festago.festago.presentation.ui.signin.SignInActivity +import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.safeNavigate +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SchoolBookmarkFragment : Fragment() { + private var _binding: FragmentSchoolBookmarkBinding? = null + private val binding get() = _binding!! + + private val vm: SchoolBookmarkViewModel by viewModels() + + private lateinit var schoolBookmarkAdapter: SchoolBookmarkAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentSchoolBookmarkBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initView() + initObserve() + vm.fetchBookmarkList() + } + + private fun initView() { + binding.uiState = vm.uiState.value + + binding.refreshListener = { vm.fetchBookmarkList() } + binding.loginListener = { vm.logIn() } + schoolBookmarkAdapter = SchoolBookmarkAdapter() + binding.rvSchoolBookmarkList.adapter = schoolBookmarkAdapter + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { uiState -> + binding.uiState = uiState + when (uiState) { + is SchoolBookmarkListUiState.Loading, + is SchoolBookmarkListUiState.Error, + is SchoolBookmarkListUiState.NotLoggedIn, + -> Unit + + is SchoolBookmarkListUiState.Success -> { + schoolBookmarkAdapter.submitList(uiState.schoolBookmarks) + } + } + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.uiEvent.collect { event -> + when (event) { + is SchoolBookmarkEvent.ShowSchoolDetail -> { + findNavController().safeNavigate( + BookmarkListFragmentDirections.actionBookmarkListFragmentToSchoolDetailFragment( + with(event.school) { SchoolDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is SchoolBookmarkEvent.ShowSignIn -> { + startActivity(SignInActivity.getIntent(requireContext())) + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkListUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkListUiState.kt new file mode 100644 index 000000000..8109e5f2c --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkListUiState.kt @@ -0,0 +1,17 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.schoolbookmark + +sealed interface SchoolBookmarkListUiState { + object NotLoggedIn : SchoolBookmarkListUiState + object Loading : SchoolBookmarkListUiState + + data class Success(val schoolBookmarks: List) : SchoolBookmarkListUiState + + object Error : SchoolBookmarkListUiState + + val shouldShowNotLoggedIn get() = this is NotLoggedIn + + val shouldShowSuccess get() = this is Success && schoolBookmarks.isNotEmpty() + val shouldShowEmpty get() = this is Success && schoolBookmarks.isEmpty() + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkUiState.kt new file mode 100644 index 000000000..4f72f2862 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.schoolbookmark + +data class SchoolBookmarkUiState( + val id: Long, + val name: String, + val imageUrl: String, + val onSchoolDetail: (school: SchoolBookmarkUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkViewHolder.kt new file mode 100644 index 000000000..6a5e569d1 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkViewHolder.kt @@ -0,0 +1,24 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.schoolbookmark + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemSchoolBookmarkBinding + +class SchoolBookmarkViewHolder(val binding: ItemSchoolBookmarkBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(item: SchoolBookmarkUiState) { + binding.school = item + } + + companion object { + fun of(parent: ViewGroup): SchoolBookmarkViewHolder { + val binding = ItemSchoolBookmarkBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return SchoolBookmarkViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkViewModel.kt new file mode 100644 index 000000000..430de5f2b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/bookmarklist/schoolbookmark/SchoolBookmarkViewModel.kt @@ -0,0 +1,64 @@ +package com.festago.festago.presentation.ui.home.bookmarklist.schoolbookmark + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.domain.model.bookmark.SchoolBookmark +import com.festago.festago.domain.repository.BookmarkRepository +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SchoolBookmarkViewModel @Inject constructor( + private val bookmarkRepository: BookmarkRepository, + private val userRepository: UserRepository, +) : ViewModel() { + private val _uiState = + MutableStateFlow(SchoolBookmarkListUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEvent = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() + + fun fetchBookmarkList() { + viewModelScope.launch { + _uiState.value = SchoolBookmarkListUiState.Loading + if (!userRepository.isSigned()) { + _uiState.value = SchoolBookmarkListUiState.NotLoggedIn + return@launch + } + bookmarkRepository.getSchoolBookmarks().onSuccess { schoolBookmarks -> + _uiState.value = SchoolBookmarkListUiState.Success( + schoolBookmarks.map { it.toUiState() }, + ) + }.onFailure { + _uiState.value = SchoolBookmarkListUiState.Error + } + } + } + + fun logIn() { + viewModelScope.launch { + _uiEvent.emit(SchoolBookmarkEvent.ShowSignIn) + } + } + + private fun SchoolBookmark.toUiState(): SchoolBookmarkUiState { + return SchoolBookmarkUiState( + id = school.id, + name = school.name, + imageUrl = school.logoUrl, + onSchoolDetail = { school -> + viewModelScope.launch { + _uiEvent.emit(SchoolBookmarkEvent.ShowSchoolDetail(school)) + } + }, + ) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListEvent.kt new file mode 100644 index 000000000..c06a22158 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListEvent.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.home.festivallist + +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState + +sealed interface FestivalListEvent { + class ShowFestivalDetail(val festival: FestivalItemUiState) : FestivalListEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt new file mode 100644 index 000000000..861129bc2 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListFragment.kt @@ -0,0 +1,226 @@ +package com.festago.festago.presentation.ui.home.festivallist + +import android.content.res.Resources +import android.graphics.Rect +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.presentation.databinding.FragmentFestivalListBinding +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailArgs +import com.festago.festago.presentation.ui.festivaldetail.FestivalDetailArgs +import com.festago.festago.presentation.ui.home.festivallist.FestivalListFragmentDirections.actionFestivalListFragmentToSearchFragment +import com.festago.festago.presentation.ui.home.festivallist.bottomsheet.RegionBottomSheetDialogFragment +import com.festago.festago.presentation.ui.home.festivallist.festival.FestivalListAdapter +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalEmptyItemUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalListUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalMoreItemUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalTabUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.SchoolRegionUiState +import com.festago.festago.presentation.ui.notificationlist.NotificationListActivity +import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.setOnApplyWindowInsetsCompatListener +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class FestivalListFragment : Fragment() { + private var _binding: FragmentFestivalListBinding? = null + private val binding get() = _binding!! + + private lateinit var festivalListAdapter: FestivalListAdapter + + private val vm: FestivalListViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentFestivalListBinding.inflate(inflater) + binding.root.setOnApplyWindowInsetsCompatListener { view, windowInsets -> + val statusBarInsets = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + view.setPadding(0, statusBarInsets.top, 0, 0) + windowInsets + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initObserve() + initView() + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { + binding.uiState = it + updateUi(it) + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { + handleEvent(it) + } + } + } + + private fun initView() { + vm.initFestivalList() + initViewPager() + initRecyclerViewDecoration() + initRefresh() + } + + private fun initRefresh() { + binding.srlFestivalList.setOnRefreshListener { + vm.initFestivalList(true) + binding.srlFestivalList.isRefreshing = false + } + binding.srlFestivalList.setDistanceToTriggerSync(400) + binding.ivSearch.setOnClickListener { + showSearch() + } + binding.ivAlarm.setOnClickListener { + showNotificationList() + } + } + + private fun initViewPager() { + festivalListAdapter = FestivalListAdapter( + onArtistClick = { artist -> + findNavController().navigate( + FestivalListFragmentDirections.actionFestivalListFragmentToArtistDetailFragment( + with(artist) { ArtistDetailArgs(id, name, imageUrl) }, + ), + ) + }, + ) + binding.rvFestivalList.adapter = festivalListAdapter + } + + private fun initRecyclerViewDecoration() { + binding.rvFestivalList.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + if (parent.getChildAdapterPosition(view) == state.itemCount - 1) { + outRect.bottom = 32.dpToPx + } + } + + private val Int.dpToPx: Int + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ).toInt() + }) + } + + private fun updateUi(uiState: FestivalListUiState) { + when (uiState) { + is FestivalListUiState.Loading -> Unit + is FestivalListUiState.Success -> handleSuccess(uiState) + is FestivalListUiState.Error -> handleError(uiState) + } + } + + private fun handleEvent(event: FestivalListEvent) { + when (event) { + is FestivalListEvent.ShowFestivalDetail -> { + findNavController().navigate( + FestivalListFragmentDirections.actionFestivalListFragmentToFestivalDetailFragment( + with(event.festival) { FestivalDetailArgs(id, name, imageUrl) }, + ), + ) + } + } + } + + private fun handleSuccess(uiState: FestivalListUiState.Success) { + val items = uiState.getItems() + festivalListAdapter.submitList(items) + binding.rvFestivalList.itemAnimator = null + } + + private fun FestivalListUiState.Success.getItems(): List { + val schoolRegions = SchoolRegion.values().map { + SchoolRegionUiState(it, it == this.schoolRegion) + } + + return mutableListOf().apply { + if (popularFestivalUiState.festivals.isNotEmpty()) { + add(popularFestivalUiState) + } + add( + FestivalTabUiState( + selectedFilter = festivalFilter, + selectedRegion = schoolRegion, + onFilterSelected = { vm.loadFestivals(it, schoolRegion) }, + ) { + createRegionDialog(schoolRegions).show(parentFragmentManager, tag) + }, + ) + addAll(festivals) + if (!isLastPage) { + add(FestivalMoreItemUiState(::requestMoreFestival)) + } else if (festivals.isEmpty()) add(FestivalEmptyItemUiState(festivalFilter.tabPosition)) + }.toList() + } + + private fun handleError(uiState: FestivalListUiState.Error) { + binding.refreshListener = uiState.refresh + } + + private fun requestMoreFestival() { + val festivalListUiState = vm.uiState.value as? FestivalListUiState.Success ?: return + if (festivalListUiState.isLastPage) return + if (festivalListUiState.festivals.isEmpty()) return + vm.loadFestivals( + schoolRegion = festivalListUiState.schoolRegion, + isLoadMore = true, + ) + } + + private fun FestivalListUiState.Success.createRegionDialog( + schoolRegions: List, + ): RegionBottomSheetDialogFragment { + return RegionBottomSheetDialogFragment().apply { + items = schoolRegions + listener = object : RegionBottomSheetDialogFragment.OnRegionSelectListener { + override fun onRegionSelect(region: SchoolRegion) { + vm.loadFestivals( + festivalFilterUiState = festivalFilter, + schoolRegion = if (region == schoolRegion) null else region, + ) + } + } + } + } + + private fun showSearch() { + findNavController().navigate(actionFestivalListFragmentToSearchFragment()) + } + + private fun showNotificationList() { + startActivity(NotificationListActivity.getIntent(requireContext())) + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt new file mode 100644 index 000000000..64d6a7542 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/FestivalListViewModel.kt @@ -0,0 +1,181 @@ +package com.festago.festago.presentation.ui.home.festivallist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.model.festival.FestivalFilter +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.domain.repository.FestivalRepository +import com.festago.festago.presentation.ui.home.festivallist.uistate.ArtistUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalFilterUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalListUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.PopularFestivalUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FestivalListViewModel @Inject constructor( + private val festivalRepository: FestivalRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FestivalListUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + private var festivalFilter: FestivalFilter = FestivalFilter.PROGRESS + + fun initFestivalList(refresh: Boolean = false) { + if (!refresh && uiState.value is FestivalListUiState.Success) return + + viewModelScope.launch { + val schoolRegion = (uiState.value as? FestivalListUiState.Success)?.schoolRegion + val deferredPopularFestivals = async { festivalRepository.loadPopularFestivals() } + val deferredFestivals = async { + festivalRepository.loadFestivals( + schoolRegion = schoolRegion, + festivalFilter = festivalFilter, + ) + } + runCatching { + val festivalsPage = deferredFestivals.await().getOrThrow() + val popularFestivals = deferredPopularFestivals.await().getOrThrow() + + _uiState.value = FestivalListUiState.Success( + PopularFestivalUiState( + title = popularFestivals.title, + festivals = popularFestivals.festivals.map { it.toUiState() }, + ), + festivals = festivalsPage.festivals.map { it.toUiState() }, + festivalFilter = festivalFilter.toUiState(), + isLastPage = festivalsPage.isLastPage, + schoolRegion = schoolRegion, + ) + }.onFailure { + handleFailure(key = KEY_INIT_FESTIVALS, throwable = it) + } + } + } + + fun loadFestivals( + festivalFilterUiState: FestivalFilterUiState? = null, + schoolRegion: SchoolRegion? = null, + isLoadMore: Boolean = false, + ) { + val successUiState = uiState.value as? FestivalListUiState.Success ?: return + + viewModelScope.launch { + val currentFestivals = getCurrentFestivals(festivalFilterUiState) + updateFestivalsState(festivalFilterUiState, successUiState) + + festivalRepository.loadFestivals( + schoolRegion = schoolRegion, + festivalFilter = festivalFilter, + lastFestivalId = if (isLoadMore) { + currentFestivals.lastOrNull()?.id + } else { + null + }, + lastStartDate = if (isLoadMore) { + currentFestivals.lastOrNull()?.startDate + } else { + null + }, + ).onSuccess { festivalsPage -> + _uiState.value = FestivalListUiState.Success( + successUiState.popularFestivalUiState, + festivals = if (isLoadMore) { + currentFestivals + festivalsPage.festivals.map { it.toUiState() } + } else { + festivalsPage.festivals.map { it.toUiState() } + }, + festivalFilter = festivalFilter.toUiState(), + schoolRegion = schoolRegion, + isLastPage = festivalsPage.isLastPage, + ) + }.onFailure { + handleFailure(key = KEY_LOAD_FESTIVALS, throwable = it) + } + } + } + + private fun handleFailure(key: String, throwable: Throwable) { + _uiState.value = FestivalListUiState.Error { + _uiState.value = FestivalListUiState.Loading + initFestivalList() + } + analyticsHelper.logNetworkFailure( + key = key, + value = throwable.message.toString(), + ) + } + + private fun updateFestivalsState( + festivalFilterUiState: FestivalFilterUiState?, + successUiState: FestivalListUiState.Success, + ) { + if (festivalFilterUiState == null) return + _uiState.value = successUiState.copy( + festivals = listOf(), + isLastPage = false, + festivalFilter = festivalFilterUiState, + ) + } + + private fun getCurrentFestivals(festivalFilterUiState: FestivalFilterUiState?): List { + var festivals = (uiState.value as? FestivalListUiState.Success)?.festivals ?: listOf() + + if (festivalFilterUiState != null && festivalFilter != festivalFilterUiState.toDomain()) { + festivalFilter = festivalFilterUiState.toDomain() + festivals = listOf() + } + return festivals + } + + private fun FestivalFilterUiState.toDomain() = when (this) { + FestivalFilterUiState.PLANNED -> FestivalFilter.PLANNED + FestivalFilterUiState.PROGRESS -> FestivalFilter.PROGRESS + } + + private fun FestivalFilter.toUiState() = when (this) { + FestivalFilter.PLANNED -> FestivalFilterUiState.PLANNED + FestivalFilter.PROGRESS -> FestivalFilterUiState.PROGRESS + else -> FestivalFilterUiState.PLANNED + } + + private fun Festival.toUiState() = FestivalItemUiState( + id = id, + name = name, + startDate = startDate, + endDate = endDate, + imageUrl = imageUrl, + artists = artists.map { artist -> + ArtistUiState(artist.id, artist.name, artist.imageUrl) + }, + ::showFestivalDetail, + ) + + private fun showFestivalDetail(festival: FestivalItemUiState) { + viewModelScope.launch { + _event.emit(FestivalListEvent.ShowFestivalDetail(festival)) + } + } + + companion object { + private const val KEY_INIT_FESTIVALS = "KEY_INIT_FESTIVALS" + private const val KEY_LOAD_FESTIVALS = "KEY_LOAD_FESTIVALS" + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionAdapter.kt new file mode 100644 index 000000000..146c7f479 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionAdapter.kt @@ -0,0 +1,25 @@ +package com.festago.festago.presentation.ui.home.festivallist.bottomsheet + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.presentation.ui.home.festivallist.uistate.SchoolRegionUiState + +class RegionAdapter( + private val items: List, + private val onRegionSelect: (SchoolRegion) -> Unit, + private val onDismiss: () -> Unit, +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return RegionItemViewHolder.of(parent, onRegionSelect, onDismiss) + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as RegionItemViewHolder).bind(items[position]) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionBottomSheetDialogFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionBottomSheetDialogFragment.kt new file mode 100644 index 000000000..8af2806de --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionBottomSheetDialogFragment.kt @@ -0,0 +1,54 @@ +package com.festago.festago.presentation.ui.home.festivallist.bottomsheet + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.presentation.databinding.FragmentRegionBottomSheetBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.SchoolRegionUiState +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class RegionBottomSheetDialogFragment() : BottomSheetDialogFragment() { + + private var _binding: FragmentRegionBottomSheetBinding? = null + private val binding: FragmentRegionBottomSheetBinding get() = _binding!! + + var items: List? = null + var listener: OnRegionSelectListener? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + super.onCreateView(inflater, container, savedInstanceState) + _binding = FragmentRegionBottomSheetBinding.inflate(inflater) + binding.lifecycleOwner = viewLifecycleOwner + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initView() + } + + private fun initView() { + val schoolRegions = items ?: return + val onRegionSelectListener = listener ?: return + + binding.rvRegionList.adapter = RegionAdapter( + items = schoolRegions, + onRegionSelect = onRegionSelectListener::onRegionSelect, + ) { dismiss() } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + interface OnRegionSelectListener { + fun onRegionSelect(region: SchoolRegion) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionItemViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionItemViewHolder.kt new file mode 100644 index 000000000..031a0f67b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/bottomsheet/RegionItemViewHolder.kt @@ -0,0 +1,40 @@ +package com.festago.festago.presentation.ui.home.festivallist.bottomsheet + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.domain.model.festival.SchoolRegion +import com.festago.festago.presentation.databinding.ItemRegionBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.SchoolRegionUiState + +class RegionItemViewHolder( + private val binding: ItemRegionBinding, + private val onRegionSelect: (SchoolRegion) -> Unit, + private val onDismiss: () -> Unit +) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: SchoolRegionUiState) { + binding.item = item + with(binding.tvRegion) { + this.isSelected = item.isSelected + setOnClickListener { + onRegionSelect(item.schoolRegion) + onDismiss.invoke() + } + } + } + + companion object { + fun of( + parent: ViewGroup, + onRegionSelect: (SchoolRegion) -> Unit, + onDismiss: () -> Unit + ): RegionItemViewHolder { + val binding = ItemRegionBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return RegionItemViewHolder(binding, onRegionSelect, onDismiss) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListAdapter.kt new file mode 100644 index 000000000..49dbb6bac --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListAdapter.kt @@ -0,0 +1,78 @@ +package com.festago.festago.presentation.ui.home.festivallist.festival + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.home.festivallist.uistate.ArtistUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalEmptyItemUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalMoreItemUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalTabUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.PopularFestivalUiState + +class FestivalListAdapter( + private val onArtistClick: (ArtistUiState) -> Unit, +) : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FestivalListViewHolder { + return when (viewType) { + 1 -> FestivalListPopularViewHolder.of(parent) + 2 -> FestivalListFestivalViewHolder.of(parent, onArtistClick) + 3 -> FestivalListTabViewHolder.of(parent) + 4 -> FestivalListMoreItemViewHolder.of(parent) + 5 -> FestivalListEmptyItemViewHolder.of(parent) + else -> throw IllegalArgumentException("Invalid viewType") + } + } + + override fun onBindViewHolder(holder: FestivalListViewHolder, position: Int) { + val item = getItem(position) + return when (holder) { + is FestivalListPopularViewHolder -> holder.bind(item as PopularFestivalUiState) + is FestivalListFestivalViewHolder -> holder.bind(item as FestivalItemUiState) + is FestivalListTabViewHolder -> holder.bind(item as FestivalTabUiState) + is FestivalListMoreItemViewHolder -> holder.bind(item as FestivalMoreItemUiState) + is FestivalListEmptyItemViewHolder -> holder.bind(item as FestivalEmptyItemUiState) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is PopularFestivalUiState -> 1 + is FestivalItemUiState -> 2 + is FestivalTabUiState -> 3 + is FestivalMoreItemUiState -> 4 + is FestivalEmptyItemUiState -> 5 + else -> throw IllegalArgumentException("Invalid item") + } + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean = when { + oldItem is PopularFestivalUiState && newItem is PopularFestivalUiState -> true + oldItem is FestivalItemUiState && newItem is FestivalItemUiState -> oldItem.id == newItem.id + oldItem is FestivalTabUiState && newItem is FestivalTabUiState -> oldItem.selectedRegion == newItem.selectedRegion + oldItem is FestivalMoreItemUiState && newItem is FestivalMoreItemUiState -> true + oldItem is FestivalEmptyItemUiState && newItem is FestivalEmptyItemUiState -> oldItem.tabPosition == newItem.tabPosition + else -> false + } + + override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean = when { + oldItem is PopularFestivalUiState && newItem is PopularFestivalUiState + -> oldItem as PopularFestivalUiState == newItem + + oldItem is FestivalItemUiState && newItem is FestivalItemUiState + -> oldItem as FestivalItemUiState == newItem + + oldItem is FestivalTabUiState && newItem is FestivalTabUiState + -> oldItem as FestivalTabUiState == newItem + + oldItem is FestivalMoreItemUiState && newItem is FestivalMoreItemUiState -> true + + oldItem is FestivalEmptyItemUiState && newItem is FestivalEmptyItemUiState -> oldItem as FestivalEmptyItemUiState == newItem + else -> false + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListEmptyItemViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListEmptyItemViewHolder.kt new file mode 100644 index 000000000..c7e442d38 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListEmptyItemViewHolder.kt @@ -0,0 +1,34 @@ +package com.festago.festago.presentation.ui.home.festivallist.festival + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemFestivalListEmptyItemBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalEmptyItemUiState + +class FestivalListEmptyItemViewHolder( + val binding: ItemFestivalListEmptyItemBinding, +) : FestivalListViewHolder(binding) { + + fun bind(festivalEmptyItemState: FestivalEmptyItemUiState) { + val emptyText = when (festivalEmptyItemState.tabPosition) { + 0 -> binding.root.context.getString(R.string.festival_list_tv_festivals_empty_progress) + + 1 -> binding.root.context.getString(R.string.festival_list_tv_festivals_empty_upcoming) + + else -> binding.root.context.getString(R.string.festival_list_tv_festivals_empty) + } + binding.tvFestivalsEmpty.text = emptyText + } + + companion object { + fun of(parent: ViewGroup): FestivalListEmptyItemViewHolder { + val binding = ItemFestivalListEmptyItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalListEmptyItemViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListFestivalViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListFestivalViewHolder.kt new file mode 100644 index 000000000..384ca62f9 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListFestivalViewHolder.kt @@ -0,0 +1,109 @@ +package com.festago.festago.presentation.ui.home.festivallist.festival + +import android.content.res.Resources +import android.graphics.Rect +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemFestivalListFestivalBinding +import com.festago.festago.presentation.ui.home.festivallist.festival.artistlist.ArtistAdapter +import com.festago.festago.presentation.ui.home.festivallist.uistate.ArtistUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState +import java.time.LocalDate + +class FestivalListFestivalViewHolder( + private val binding: ItemFestivalListFestivalBinding, + onArtistClick: (ArtistUiState) -> Unit, +) : + FestivalListViewHolder(binding) { + private val artistAdapter = ArtistAdapter(onArtistClick) + + init { + binding.rvFestivalArtists.adapter = artistAdapter + binding.rvFestivalArtists.addItemDecoration(ArtistItemDecoration()) + binding.rvFestivalArtists.itemAnimator = null + } + + fun bind(item: FestivalItemUiState) { + binding.item = item + artistAdapter.submitList(item.artists) + binding.tvEmptyStage.visibility = if (item.artists.isEmpty()) View.VISIBLE else View.GONE + bindDDayView(item) + } + + private fun bindDDayView(item: FestivalItemUiState) { + val context = binding.root.context + + when { + LocalDate.now() in item.startDate..item.endDate -> { + binding.tvFestivalDDay.text = + context.getString(R.string.tv_dday_in_progress) + binding.tvFestivalDDay.setTextColor(context.getColor(R.color.secondary_pink_01)) + binding.tvFestivalDDay.background = AppCompatResources.getDrawable( + context, + R.drawable.bg_festival_list_dday_in_progress, + ) + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + } + + LocalDate.now() < item.startDate -> { + val dDay = LocalDate.now().toEpochDay() - item.startDate.toEpochDay() + val backgroundColor = if (dDay >= -7L) { + context.getColor(R.color.secondary_pink_01) + } else { + context.getColor(R.color.contents_gray_07) + } + binding.tvFestivalDDay.setBackgroundColor(backgroundColor) + binding.tvFestivalDDay.setTextColor(context.getColor(R.color.background_gray_01)) + binding.tvFestivalDDay.text = + context.getString(R.string.tv_dday_format, dDay.toString()) + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + } + + else -> { + binding.tvFestivalDDay.visibility = View.GONE + binding.tvFestivalDDayEnd.visibility = View.VISIBLE + } + } + } + + private class ArtistItemDecoration : ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + outRect.right = 8.dpToPx + } + + private val Int.dpToPx: Int + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ).toInt() + } + + companion object { + fun of( + parent: ViewGroup, + onArtistClick: (ArtistUiState) -> Unit, + ): FestivalListFestivalViewHolder { + val binding = ItemFestivalListFestivalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalListFestivalViewHolder(binding, onArtistClick) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListMoreItemViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListMoreItemViewHolder.kt new file mode 100644 index 000000000..bfbafae3c --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListMoreItemViewHolder.kt @@ -0,0 +1,25 @@ +package com.festago.festago.presentation.ui.home.festivallist.festival + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemFestivalListMoreItemBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalMoreItemUiState + +class FestivalListMoreItemViewHolder(val binding: ItemFestivalListMoreItemBinding) : + FestivalListViewHolder(binding) { + + fun bind(festivalMoreItemUiState: FestivalMoreItemUiState) { + festivalMoreItemUiState.requestMoreItem() + } + + companion object { + fun of(parent: ViewGroup): FestivalListMoreItemViewHolder { + val binding = ItemFestivalListMoreItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalListMoreItemViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListPopularViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListPopularViewHolder.kt new file mode 100644 index 000000000..64bd3c047 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListPopularViewHolder.kt @@ -0,0 +1,48 @@ +package com.festago.festago.presentation.ui.home.festivallist.festival + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemFestivalListPopularBinding +import com.festago.festago.presentation.ui.home.festivallist.popularfestival.PopularFestivalViewPagerAdapter +import com.festago.festago.presentation.ui.home.festivallist.uistate.PopularFestivalUiState +import com.google.android.material.tabs.TabLayoutMediator + +class FestivalListPopularViewHolder(val binding: ItemFestivalListPopularBinding) : + FestivalListViewHolder(binding) { + private val popularFestivalViewPager: PopularFestivalViewPagerAdapter = + PopularFestivalViewPagerAdapter( + foregroundViewPager = binding.vpPopularFestivalForeground, + backgroundViewPager = binding.vpPopularFestivalBackground, + ) { item -> + binding.item = item + binding.tvPopularFestivalArtistsName.text = + item.artists.joinToString(ARTIST_NAME_JOIN_SEPARATOR) { it.name } + } + + init { + TabLayoutMediator( + binding.tlDotIndicator, + binding.vpPopularFestivalBackground, + ) { tab, position -> + tab.view.isClickable = false + }.attach() + } + + fun bind(popularFestivalUiState: PopularFestivalUiState) { + binding.tvPopularFestivalTitle.text = popularFestivalUiState.title + popularFestivalViewPager.submitList(popularFestivalUiState.festivals) + } + + companion object { + private const val ARTIST_NAME_JOIN_SEPARATOR = ", " + + fun of(parent: ViewGroup): FestivalListPopularViewHolder { + val binding = ItemFestivalListPopularBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalListPopularViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListTabViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListTabViewHolder.kt new file mode 100644 index 000000000..2c3b8ca58 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListTabViewHolder.kt @@ -0,0 +1,72 @@ +package com.festago.festago.presentation.ui.home.festivallist.festival + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemFestivalListTabBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalFilterUiState +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalTabUiState +import com.google.android.material.tabs.TabLayout + +class FestivalListTabViewHolder(val binding: ItemFestivalListTabBinding) : + FestivalListViewHolder(binding) { + init { + binding.tlFestivalListTab.addTab( + binding.tlFestivalListTab.newTab().setText( + binding.root.context.getText(R.string.festival_list_ti_current_festival), + ), + ) + binding.tlFestivalListTab.addTab( + binding.tlFestivalListTab.newTab().setText( + binding.root.context.getText(R.string.festival_list_ti_upcoming_festival), + ), + ) + } + + fun bind(festivalTabUiState: FestivalTabUiState) { + with(binding.tlFestivalListTab) { + clearOnTabSelectedListeners() + selectTab(getTabAt(festivalTabUiState.selectedFilter.tabPosition)) + addOnTabSelectedListener( + object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + festivalTabUiState.onFilterSelected(getFestivalFilterAt(tab.position)) + } + + override fun onTabUnselected(tab: TabLayout.Tab) = Unit + override fun onTabReselected(tab: TabLayout.Tab) = Unit + }, + ) + } + binding.schoolRegion = festivalTabUiState.selectedRegion + binding.tvRegion.setOnClickListener { + festivalTabUiState.onRegionClick.invoke() + } + if (festivalTabUiState.selectedRegion == null) { + binding.tvRegion.isSelected = false + binding.tvRegion.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_pin_normal, 0, 0, 0 + ) + } else { + binding.tvRegion.isSelected = true + binding.tvRegion.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_pin_select, 0, 0, 0 + ) + } + } + + private fun getFestivalFilterAt(position: Int): FestivalFilterUiState = + FestivalFilterUiState.values().find { it.tabPosition == position } + ?: FestivalFilterUiState.PROGRESS + + companion object { + fun of(parent: ViewGroup): FestivalListTabViewHolder { + val binding = ItemFestivalListTabBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalListTabViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListViewHolder.kt new file mode 100644 index 000000000..ef747eaca --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/FestivalListViewHolder.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.home.festivallist.festival + +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView + +sealed class FestivalListViewHolder(binding: ViewDataBinding) : + RecyclerView.ViewHolder(binding.root) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/artistlist/ArtistAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/artistlist/ArtistAdapter.kt new file mode 100644 index 000000000..560c9463d --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/artistlist/ArtistAdapter.kt @@ -0,0 +1,36 @@ +package com.festago.festago.presentation.ui.home.festivallist.festival.artistlist + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.home.festivallist.uistate.ArtistUiState + +class ArtistAdapter( + private val onArtistClick: (ArtistUiState) -> Unit, +) : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder { + return ArtistViewHolder.of(parent, onArtistClick) + } + + override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/artistlist/ArtistViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/artistlist/ArtistViewHolder.kt new file mode 100644 index 000000000..b28d553d4 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/festival/artistlist/ArtistViewHolder.kt @@ -0,0 +1,33 @@ +package com.festago.festago.presentation.ui.home.festivallist.festival.artistlist + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemFestivalListArtistBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.ArtistUiState + +class ArtistViewHolder( + private val binding: ItemFestivalListArtistBinding, + onArtistClick: (ArtistUiState) -> Unit, +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.root.setOnClickListener { + onArtistClick(binding.artist!!) + } + } + + fun bind(item: ArtistUiState) { + binding.artist = item + } + + companion object { + fun of(parent: ViewGroup, onArtistClick: (ArtistUiState) -> Unit): ArtistViewHolder { + val binding = ItemFestivalListArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistViewHolder(binding, onArtistClick) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/PopularFestivalViewPagerAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/PopularFestivalViewPagerAdapter.kt new file mode 100644 index 000000000..3357e515f --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/PopularFestivalViewPagerAdapter.kt @@ -0,0 +1,113 @@ +package com.festago.festago.presentation.ui.home.festivallist.popularfestival + +import android.content.res.Resources +import android.view.View +import androidx.viewpager2.widget.ViewPager2 +import com.festago.festago.presentation.ui.home.festivallist.popularfestival.background.PopularFestivalBackgroundAdapter +import com.festago.festago.presentation.ui.home.festivallist.popularfestival.foreground.PopularFestivalForegroundAdapter +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState +import kotlin.math.abs + +class PopularFestivalViewPagerAdapter( + private val foregroundViewPager: ViewPager2, + backgroundViewPager: ViewPager2, + private val onPopularFestivalSelected: (FestivalItemUiState) -> Unit, +) { + + private val popularFestivals = mutableListOf() + private val foregroundAdapter: PopularFestivalForegroundAdapter = + PopularFestivalForegroundAdapter() + private val backgroundAdapter: PopularFestivalBackgroundAdapter = + PopularFestivalBackgroundAdapter() + + init { + foregroundViewPager.adapter = foregroundAdapter + backgroundViewPager.adapter = backgroundAdapter + + setTargetItemOnPageSelected(viewPager = foregroundViewPager, target = backgroundViewPager) + narrowSpaceViewPager(viewPager = foregroundViewPager) + setOffscreenPagesLimit(foregroundViewPager, PAGE_LIMIT) + setOffscreenPagesLimit(backgroundViewPager, PAGE_LIMIT) + backgroundViewPager.isUserInputEnabled = false + } + + private fun setTargetItemOnPageSelected(viewPager: ViewPager2, target: ViewPager2) { + val onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + val itemIndex = position % popularFestivals.size + target.setCurrentItem(itemIndex, false) + onPopularFestivalSelected(popularFestivals[itemIndex]) + } + } + viewPager.registerOnPageChangeCallback(onPageChangeCallback) + } + + private fun narrowSpaceViewPager(viewPager: ViewPager2) { + viewPager.setPageTransformer { page, position -> + translateOffsetBetweenPages(position, page) + reduceUnselectedPagesScale(page, position) + } + } + + private fun translateOffsetBetweenPages(position: Float, page: View) { + val screenWidth = Resources.getSystem().configuration.screenWidthDp + val offsetBetweenPages = screenWidth - IMAGE_SIZE - INTERVAL_IMAGE + val reducedDifference = IMAGE_SIZE - (IMAGE_SIZE * RATE_SELECT_BY_UNSELECT) + val offset = position * -dpToPx(offsetBetweenPages + reducedDifference * 0.5f) + page.translationX = offset + } + + private fun dpToPx(dp: Float): Int { + val density = Resources.getSystem().displayMetrics.density + return (dp * density).toInt() + } + + private fun reduceUnselectedPagesScale(page: View, position: Float) { + page.movePivotTo(x = page.pivotX, y = MIDDLE_IMAGE_PIVOT) + if (position <= ALREADY_LOAD_POSITION_CONDITION) { + page.reduceScaleBy(position = position, rate = RATE_SELECT_BY_UNSELECT) + } + } + + private fun View.movePivotTo(x: Float, y: Float) { + pivotX = x + pivotY = y + } + + private fun View.reduceScaleBy(position: Float, rate: Float) { + val scaleFactor = rate.coerceAtLeast(1 - abs(position)) + scaleY = scaleFactor + scaleX = scaleFactor + } + + private fun setOffscreenPagesLimit(viewPager: ViewPager2, limit: Int) { + viewPager.offscreenPageLimit = limit + } + + fun submitList(festivals: List) { + val lastFestivals = popularFestivals.toList() + popularFestivals.clear() + popularFestivals.addAll(festivals) + foregroundAdapter.submitList(festivals) + backgroundAdapter.submitList(festivals) + + if (lastFestivals != festivals) { + initItemPosition() + } + } + + private fun initItemPosition() { + val initialPosition = Int.MAX_VALUE / 2 - (Int.MAX_VALUE / 2 % popularFestivals.size) + foregroundViewPager.setCurrentItem(initialPosition, false) + } + + companion object { + private const val ALREADY_LOAD_POSITION_CONDITION = 2 + private const val RATE_SELECT_BY_UNSELECT = 0.81f + private const val PAGE_LIMIT = 4 + private const val IMAGE_SIZE = 220.0f + private const val INTERVAL_IMAGE = 24.0f + private const val MIDDLE_IMAGE_PIVOT = 360.0f + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/background/PopularFestivalBackgroundAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/background/PopularFestivalBackgroundAdapter.kt new file mode 100644 index 000000000..53bdd6d23 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/background/PopularFestivalBackgroundAdapter.kt @@ -0,0 +1,39 @@ +package com.festago.festago.presentation.ui.home.festivallist.popularfestival.background + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState + +class PopularFestivalBackgroundAdapter : + ListAdapter(diffUtil) { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): PopularFestivalBackgroundViewHolder { + return PopularFestivalBackgroundViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: PopularFestivalBackgroundViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: FestivalItemUiState, + newItem: FestivalItemUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: FestivalItemUiState, + newItem: FestivalItemUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/background/PopularFestivalBackgroundViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/background/PopularFestivalBackgroundViewHolder.kt new file mode 100644 index 000000000..52849ec92 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/background/PopularFestivalBackgroundViewHolder.kt @@ -0,0 +1,28 @@ +package com.festago.festago.presentation.ui.home.festivallist.popularfestival.background + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemPopularFestivalBackgroundBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState + +class PopularFestivalBackgroundViewHolder( + private val binding: ItemPopularFestivalBackgroundBinding, +) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: FestivalItemUiState) { + binding.item = item + } + + companion object { + fun of(parent: ViewGroup): PopularFestivalBackgroundViewHolder { + val binding = ItemPopularFestivalBackgroundBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return PopularFestivalBackgroundViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundAdapter.kt new file mode 100644 index 000000000..03548fbaf --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundAdapter.kt @@ -0,0 +1,33 @@ +package com.festago.festago.presentation.ui.home.festivallist.popularfestival.foreground + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState + +class PopularFestivalForegroundAdapter(festivals: List = listOf()) : + RecyclerView.Adapter() { + + private val _festivals = festivals.toMutableList() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): PopularFestivalForegroundViewHolder { + return PopularFestivalForegroundViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: PopularFestivalForegroundViewHolder, position: Int) { + holder.bind(_festivals[position % _festivals.size]) + } + + override fun getItemCount(): Int = Int.MAX_VALUE + + fun submitList(festivals: List) { + if (_festivals.toList() == festivals) { + return + } + _festivals.clear() + _festivals.addAll(festivals) + notifyDataSetChanged() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundViewHolder.kt new file mode 100644 index 000000000..c60b575d1 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/popularfestival/foreground/PopularFestivalForegroundViewHolder.kt @@ -0,0 +1,29 @@ +package com.festago.festago.presentation.ui.home.festivallist.popularfestival.foreground + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemPopularFestivalForegroundBinding +import com.festago.festago.presentation.ui.home.festivallist.uistate.FestivalItemUiState + +class PopularFestivalForegroundViewHolder( + private val binding: ItemPopularFestivalForegroundBinding, +) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: FestivalItemUiState) { + binding.item = item + } + + companion object { + + fun of(parent: ViewGroup): PopularFestivalForegroundViewHolder { + val binding = ItemPopularFestivalForegroundBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return PopularFestivalForegroundViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/ArtistUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/ArtistUiState.kt new file mode 100644 index 000000000..c423ad9d6 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/ArtistUiState.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +data class ArtistUiState( + val id: Long, + val name: String, + val imageUrl: String, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalEmptyItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalEmptyItemUiState.kt new file mode 100644 index 000000000..0733dcb25 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalEmptyItemUiState.kt @@ -0,0 +1,3 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +data class FestivalEmptyItemUiState(val tabPosition: Int) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalFilterUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalFilterUiState.kt new file mode 100644 index 000000000..092ad203c --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalFilterUiState.kt @@ -0,0 +1,5 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +enum class FestivalFilterUiState(val tabPosition: Int) { + PROGRESS(0), PLANNED(1) +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalItemUiState.kt new file mode 100644 index 000000000..a809c8d6f --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalItemUiState.kt @@ -0,0 +1,13 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +import java.time.LocalDate + +data class FestivalItemUiState( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val imageUrl: String, + val artists: List, + val onFestivalDetail: (festival: FestivalItemUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt new file mode 100644 index 000000000..b555eeac8 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalListUiState.kt @@ -0,0 +1,21 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +import com.festago.festago.domain.model.festival.SchoolRegion + +sealed interface FestivalListUiState { + object Loading : FestivalListUiState + + data class Success( + val popularFestivalUiState: PopularFestivalUiState, + val festivals: List, + val festivalFilter: FestivalFilterUiState, + val isLastPage: Boolean, + val schoolRegion: SchoolRegion? = null, + ) : FestivalListUiState + + class Error(val refresh: () -> Unit) : FestivalListUiState + + val shouldShowSuccess get() = this is Success + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalMoreItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalMoreItemUiState.kt new file mode 100644 index 000000000..108479b7c --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalMoreItemUiState.kt @@ -0,0 +1,3 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +class FestivalMoreItemUiState(val requestMoreItem: () -> Unit) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalTabUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalTabUiState.kt new file mode 100644 index 000000000..79601dfda --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/FestivalTabUiState.kt @@ -0,0 +1,10 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +import com.festago.festago.domain.model.festival.SchoolRegion + +data class FestivalTabUiState( + val selectedFilter: FestivalFilterUiState, + val selectedRegion: SchoolRegion?, + val onFilterSelected: (FestivalFilterUiState) -> Unit, + val onRegionClick: () -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/PopularFestivalUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/PopularFestivalUiState.kt new file mode 100644 index 000000000..55a6f3d7e --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/PopularFestivalUiState.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +data class PopularFestivalUiState( + val title: String, + val festivals: List, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/SchoolRegionUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/SchoolRegionUiState.kt new file mode 100644 index 000000000..dc674bf84 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/SchoolRegionUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +import com.festago.festago.domain.model.festival.SchoolRegion + +data class SchoolRegionUiState( + val schoolRegion: SchoolRegion, + val isSelected: Boolean, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/SchoolUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/SchoolUiState.kt new file mode 100644 index 000000000..4545a42f7 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/festivallist/uistate/SchoolUiState.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.home.festivallist.uistate + +data class SchoolUiState( + val id: Long, + val name: String, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageEvent.kt new file mode 100644 index 000000000..482cf2383 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageEvent.kt @@ -0,0 +1,5 @@ +package com.festago.festago.presentation.ui.home.mypage + +sealed interface MyPageEvent { + object ShowSignIn : MyPageEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt new file mode 100644 index 000000000..a5496471b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageFragment.kt @@ -0,0 +1,173 @@ +package com.festago.festago.presentation.ui.home.mypage + +import android.app.AlertDialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.FragmentMyPageBinding +import com.festago.festago.presentation.ui.signin.SignInActivity +import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.setOnApplyWindowInsetsCompatListener +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MyPageFragment : Fragment() { + private var _binding: FragmentMyPageBinding? = null + private val binding get() = _binding!! + + private val vm: MyPageViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentMyPageBinding.inflate(inflater) + binding.root.setOnApplyWindowInsetsCompatListener { view, windowInsets -> + val statusBarInsets = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + view.setPadding(0, statusBarInsets.top, 0, 0) + windowInsets + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initView() + initObserve() + } + + private fun initView() { + vm.loadUserInfo() + initSignInBtn() + initPersonalInfoBtn() + initAppVersionBtn() + initLogoutBtn() + initAskQuestionBtn() + initDeleteAccountBtn() + } + + private fun initPersonalInfoBtn() { + binding.tvPersonalInfoPolicy.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = + Uri.parse("https://sites.google.com/view/privacy-festago") + startActivity(intent) + } + } + + private fun initAskQuestionBtn() { + binding.tvAskQuestion.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = + Uri.parse("https://forms.gle/UWQe28gEUFy3AWPB8") + startActivity(intent) + } + } + + private fun initSignInBtn() { + binding.llMyPageLogin.setOnClickListener { + vm.signIn() + } + } + + private fun initAppVersionBtn() { + binding.tvAppVersion.setOnClickListener { + showAppVersion() + } + } + + private fun initLogoutBtn() { + binding.tvLogout.setOnClickListener { + alertDialog( + getString(R.string.my_page_logout_alert_title), + getString(R.string.my_page_logout_alert_message), + action = { vm.signOut() }, + cancel = { }, + ) + } + } + + private fun initDeleteAccountBtn() { + binding.tvDeleteAccount.setOnClickListener { + alertDialog( + getString(R.string.my_page_delete_account_alert_title), + getString(R.string.my_page_delete_account_alert_message), + action = { vm.deleteAccount() }, + cancel = { }, + ) + } + } + + private fun showAppVersion() { + val appVersion = requireActivity() + .packageManager + .getPackageInfo(requireActivity().packageName, 0) + .versionName + + Toast.makeText( + requireContext(), + getString(R.string.my_page_app_version_format, appVersion), + Toast.LENGTH_SHORT, + ).show() + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { uiState -> + binding.uiState = uiState + when (uiState) { + is MyPageUiState.Loading, + is MyPageUiState.NotLoggedIn, + -> Unit + + is MyPageUiState.Success -> { + binding.successUiState = uiState + } + + is MyPageUiState.Error -> { + binding.errorUiState = uiState + } + } + } + } + + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { event -> + when (event) { + MyPageEvent.ShowSignIn -> { + startActivity(SignInActivity.getIntent(requireContext())) + } + } + } + } + } + + private fun alertDialog( + title: String, + message: String, + action: () -> Unit, + cancel: () -> Unit, + ) { + AlertDialog.Builder(requireContext()).apply { + setTitle(title) + setMessage(message) + setPositiveButton(R.string.ok_dialog_btn_ok) { _, _ -> action() } + setNegativeButton(R.string.ok_dialog_btn_cancel) { _, _ -> cancel() } + setCancelable(false) + }.show() + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageUiState.kt new file mode 100644 index 000000000..39bb9df16 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageUiState.kt @@ -0,0 +1,22 @@ +package com.festago.festago.presentation.ui.home.mypage + +import com.festago.festago.domain.model.user.UserInfo + +sealed interface MyPageUiState { + object Loading : MyPageUiState + + class Success( + val userInfo: UserInfo, + ) : MyPageUiState + + object NotLoggedIn : MyPageUiState + + class Error( + val refresh: () -> Unit, + ) : MyPageUiState + + val shouldShowNotLoggedIn get() = this is NotLoggedIn + val shouldShowSuccess get() = this is Success + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModel.kt new file mode 100644 index 000000000..6f9dd8c4f --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/mypage/MyPageViewModel.kt @@ -0,0 +1,72 @@ +package com.festago.festago.presentation.ui.home.mypage + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MyPageViewModel @Inject constructor( + private val userRepository: UserRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(MyPageUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event: MutableSharedFlow = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun loadUserInfo() { + viewModelScope.launch { + _uiState.value = MyPageUiState.Loading + + if (!userRepository.isSigned()) { + _uiState.value = MyPageUiState.NotLoggedIn + return@launch + } + + userRepository.getUserInfo().onSuccess { + _uiState.value = MyPageUiState.Success(it) + }.onFailure { + _uiState.value = MyPageUiState.Error(refresh = ::loadUserInfo) + } + } + } + + fun signIn() { + viewModelScope.launch { + _event.emit(MyPageEvent.ShowSignIn) + } + } + + fun signOut() { + viewModelScope.launch { + if (!userRepository.isSigned()) return@launch + + userRepository.signOut().onSuccess { + _event.emit(MyPageEvent.ShowSignIn) + }.onFailure { + _uiState.value = MyPageUiState.Error(refresh = ::loadUserInfo) + } + } + } + + fun deleteAccount() { + viewModelScope.launch { + if (!userRepository.isSigned()) return@launch + + userRepository.deleteAccount().onSuccess { + _event.emit(MyPageEvent.ShowSignIn) + }.onFailure { + _uiState.value = MyPageUiState.Error(refresh = ::loadUserInfo) + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListFragment.kt new file mode 100644 index 000000000..f4cd74eb7 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/home/ticketlist/TicketListFragment.kt @@ -0,0 +1,20 @@ +package com.festago.festago.presentation.ui.home.ticketlist + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.festago.festago.presentation.R + +class TicketListFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_ticket_list, container, false) + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/notificationlist/NotificationListActivity.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/notificationlist/NotificationListActivity.kt new file mode 100644 index 000000000..5706c9d02 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/notificationlist/NotificationListActivity.kt @@ -0,0 +1,35 @@ +package com.festago.festago.presentation.ui.notificationlist + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import com.festago.festago.presentation.databinding.ActivityNotificationListBinding + +class NotificationListActivity : AppCompatActivity() { + + private val binding: ActivityNotificationListBinding by lazy { + ActivityNotificationListBinding.inflate(layoutInflater) + } + + private val vm: NotificationListViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + initBackPressed() + } + + private fun initBackPressed() { + binding.ivBack.setOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + } + + companion object { + fun getIntent(context: Context): Intent { + return Intent(context, NotificationListActivity::class.java) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/notificationlist/NotificationListViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/notificationlist/NotificationListViewModel.kt new file mode 100644 index 000000000..e00cd54e6 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/notificationlist/NotificationListViewModel.kt @@ -0,0 +1,5 @@ +package com.festago.festago.presentation.ui.notificationlist + +import androidx.lifecycle.ViewModel + +class NotificationListViewModel : ViewModel() diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistAdapter.kt new file mode 100644 index 000000000..0393024f6 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistAdapter.kt @@ -0,0 +1,35 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.schooldetail.uistate.ArtistUiState + +class ArtistAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder { + return ArtistViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistViewHolder.kt new file mode 100644 index 000000000..6011c50ca --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/ArtistViewHolder.kt @@ -0,0 +1,27 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemSchoolDetailArtistBinding +import com.festago.festago.presentation.ui.schooldetail.uistate.ArtistUiState + +class ArtistViewHolder( + private val binding: ItemSchoolDetailArtistBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ArtistUiState) { + binding.artist = item + } + + companion object { + fun of(parent: ViewGroup): ArtistViewHolder { + val binding = ItemSchoolDetailArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailArgs.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailArgs.kt new file mode 100644 index 000000000..02f2e9839 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailArgs.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SchoolDetailArgs(val id: Long, val name: String, val profileImageUrl: String) : + Parcelable diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailEvent.kt new file mode 100644 index 000000000..7bb1d5a5f --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailEvent.kt @@ -0,0 +1,14 @@ +package com.festago.festago.presentation.ui.schooldetail + +import com.festago.festago.presentation.ui.schooldetail.uistate.ArtistUiState +import com.festago.festago.presentation.ui.schooldetail.uistate.FestivalItemUiState + +sealed interface SchoolDetailEvent { + class ShowArtistDetail(val artist: ArtistUiState) : SchoolDetailEvent + + class ShowFestivalDetail(val festival: FestivalItemUiState) : SchoolDetailEvent + + class BookmarkSuccess(val isBookmarked: Boolean) : SchoolDetailEvent + + class BookmarkFailure(val message: String) : SchoolDetailEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFestivalViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFestivalViewHolder.kt new file mode 100644 index 000000000..3450236c5 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFestivalViewHolder.kt @@ -0,0 +1,102 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.content.res.Resources +import android.graphics.Rect +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemSchoolDetailFestivalBinding +import com.festago.festago.presentation.ui.schooldetail.uistate.FestivalItemUiState +import java.time.LocalDate + +class SchoolDetailFestivalViewHolder( + private val binding: ItemSchoolDetailFestivalBinding, +) : SchoolDetailViewHolder(binding) { + private val artistAdapter = ArtistAdapter() + + init { + binding.rvFestivalArtists.adapter = artistAdapter + binding.rvFestivalArtists.addItemDecoration(ArtistItemDecoration()) + binding.rvFestivalArtists.itemAnimator = null + } + + fun bind(item: FestivalItemUiState) { + binding.item = item + artistAdapter.submitList(item.artists) + binding.tvEmptyStage.visibility = if (item.artists.isEmpty()) View.VISIBLE else View.GONE + bindDDayView(item) + } + + private fun bindDDayView(item: FestivalItemUiState) { + val context = binding.root.context + + when { + LocalDate.now() in item.startDate..item.endDate -> { + binding.tvFestivalDDay.text = + context.getString(R.string.tv_dday_in_progress) + binding.tvFestivalDDay.setTextColor(context.getColor(R.color.secondary_pink_01)) + binding.tvFestivalDDay.background = AppCompatResources.getDrawable( + context, + R.drawable.bg_festival_list_dday_in_progress, + ) + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + } + + LocalDate.now() < item.startDate -> { + val dDay = LocalDate.now().toEpochDay() - item.startDate.toEpochDay() + val backgroundColor = if (dDay >= -7L) { + context.getColor(R.color.secondary_pink_01) + } else { + context.getColor(R.color.contents_gray_07) + } + binding.tvFestivalDDay.setBackgroundColor(backgroundColor) + binding.tvFestivalDDay.setTextColor(context.getColor(R.color.background_gray_01)) + binding.tvFestivalDDay.text = + context.getString(R.string.tv_dday_format, dDay.toString()) + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + } + + else -> { + binding.tvFestivalDDay.visibility = View.GONE + binding.tvFestivalDDayEnd.visibility = View.VISIBLE + } + } + } + + private class ArtistItemDecoration : ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + outRect.right = 8.dpToPx + } + + private val Int.dpToPx: Int + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ).toInt() + } + + companion object { + fun of(parent: ViewGroup): SchoolDetailFestivalViewHolder { + val binding = ItemSchoolDetailFestivalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return SchoolDetailFestivalViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFragment.kt new file mode 100644 index 000000000..7f16a4ed8 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailFragment.kt @@ -0,0 +1,176 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.festago.festago.domain.model.social.SocialMedia +import com.festago.festago.domain.model.social.SocialMediaType +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.FragmentSchoolDetailBinding +import com.festago.festago.presentation.databinding.ItemMediaBinding +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailArgs +import com.festago.festago.presentation.ui.festivaldetail.FestivalDetailArgs +import com.festago.festago.presentation.ui.schooldetail.uistate.MoreItemUiState +import com.festago.festago.presentation.ui.schooldetail.uistate.SchoolDetailUiState +import com.festago.festago.presentation.util.repeatOnStarted +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SchoolDetailFragment : Fragment() { + + private var _binding: FragmentSchoolDetailBinding? = null + private val binding get() = _binding!! + + private val vm: SchoolDetailViewModel by viewModels() + + private lateinit var adapter: SchoolFestivalListAdapter + + private val args: SchoolDetailFragmentArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentSchoolDetailBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initView() + initObserve() + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { + binding.uiState = it + updateUi(it) + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { + handleEvent(it) + } + } + } + + private fun initView() { + adapter = SchoolFestivalListAdapter() + binding.rvFestivalList.adapter = adapter + loadSchoolDetail() + initButton() + } + + private fun loadSchoolDetail() { + binding.tvSchoolName.text = args.school.name + val delayTimeMillis = resources.getInteger(R.integer.nav_Anim_time).toLong() + vm.loadSchoolDetail(args.school.id, delayTimeMillis) + } + + private fun initButton() { + binding.ivBack.setOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + private fun updateUi(uiState: SchoolDetailUiState) { + when (uiState) { + is SchoolDetailUiState.Loading -> Unit + is SchoolDetailUiState.Success -> handleSuccess(uiState) + is SchoolDetailUiState.Error -> handleError(uiState) + } + } + + private fun handleSuccess(uiState: SchoolDetailUiState.Success) { + binding.successUiState = uiState + binding.ivBookmark.isSelected = uiState.bookmarked + binding.tvSchoolName.text = uiState.schoolInfo.schoolName + val items: List = if (uiState.isLast) { + uiState.festivals + } else { + uiState.festivals + MoreItemUiState { vm.loadMoreSchoolFestivals(args.school.id) } + } + adapter.submitList(items) + + binding.llcSchoolSocialMedia.removeAllViews() + + uiState.schoolInfo.socialMedia.forEach { media -> + with(ItemMediaBinding.inflate(layoutInflater, binding.llcSchoolSocialMedia, false)) { + if (media.type == SocialMediaType.NONE) return@with + ivImage.setImageResource(findMediaRes(media)) + ivImage.setOnClickListener { startBrowser(media.url) } + binding.llcSchoolSocialMedia.addView(ivImage) + } + } + } + + private fun findMediaRes(media: SocialMedia): Int { + val res = when (media.type) { + SocialMediaType.INSTAGRAM -> R.drawable.ic_instagram + SocialMediaType.FACEBOOK -> R.drawable.ic_facebook + SocialMediaType.YOUTUBE -> R.drawable.ic_youtube + SocialMediaType.X -> R.drawable.ic_x + else -> R.drawable.bg_festago_default + } + return res + } + + private fun handleError(uiState: SchoolDetailUiState.Error) { + binding.refreshListener = { uiState.refresh(args.school.id) } + } + + private fun handleEvent(event: SchoolDetailEvent) = when (event) { + is SchoolDetailEvent.ShowArtistDetail -> { + findNavController().navigate( + SchoolDetailFragmentDirections.actionSchoolDetailFragmentToArtistDetailFragment( + with(event.artist) { ArtistDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is SchoolDetailEvent.ShowFestivalDetail -> { + findNavController().navigate( + SchoolDetailFragmentDirections.actionSchoolDetailFragmentToFestivalDetailFragment( + with(event.festival) { FestivalDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is SchoolDetailEvent.BookmarkSuccess -> { + Toast.makeText( + requireContext(), + if (event.isBookmarked) { + getString(R.string.school_detail_bookmark_success) + } else { + getString(R.string.school_detail_bookmark_cancel) + }, + Toast.LENGTH_SHORT, + ).show() + } + + is SchoolDetailEvent.BookmarkFailure -> { + Toast.makeText(requireContext(), event.message, Toast.LENGTH_SHORT).show() + } + } + + private fun startBrowser(url: String) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailMoreItemViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailMoreItemViewHolder.kt new file mode 100644 index 000000000..c1726d6e1 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailMoreItemViewHolder.kt @@ -0,0 +1,25 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemSchoolDetailMoreItemBinding +import com.festago.festago.presentation.ui.schooldetail.uistate.MoreItemUiState + +class SchoolDetailMoreItemViewHolder( + binding: ItemSchoolDetailMoreItemBinding +) : SchoolDetailViewHolder(binding) { + fun bind(item: MoreItemUiState) { + item.requestMore() + } + + companion object { + fun of(parent: ViewGroup): SchoolDetailMoreItemViewHolder { + val binding = ItemSchoolDetailMoreItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return SchoolDetailMoreItemViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewHolder.kt new file mode 100644 index 000000000..cb524fc32 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewHolder.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.schooldetail + +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView + +sealed class SchoolDetailViewHolder(binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewModel.kt new file mode 100644 index 000000000..15889257b --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolDetailViewModel.kt @@ -0,0 +1,193 @@ +package com.festago.festago.presentation.ui.schooldetail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.domain.exception.isBookmarkLimitExceeded +import com.festago.festago.domain.exception.isNetworkError +import com.festago.festago.domain.exception.isUnauthorized +import com.festago.festago.domain.model.bookmark.BookmarkType +import com.festago.festago.domain.model.festival.Festival +import com.festago.festago.domain.repository.BookmarkRepository +import com.festago.festago.domain.repository.SchoolRepository +import com.festago.festago.domain.repository.UserRepository +import com.festago.festago.presentation.ui.schooldetail.SchoolDetailEvent.BookmarkFailure +import com.festago.festago.presentation.ui.schooldetail.SchoolDetailEvent.BookmarkSuccess +import com.festago.festago.presentation.ui.schooldetail.SchoolDetailEvent.ShowArtistDetail +import com.festago.festago.presentation.ui.schooldetail.SchoolDetailEvent.ShowFestivalDetail +import com.festago.festago.presentation.ui.schooldetail.uistate.ArtistUiState +import com.festago.festago.presentation.ui.schooldetail.uistate.FestivalItemUiState +import com.festago.festago.presentation.ui.schooldetail.uistate.SchoolDetailUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class SchoolDetailViewModel @Inject constructor( + private val schoolRepository: SchoolRepository, + private val bookmarkRepository: BookmarkRepository, + private val userRepository: UserRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SchoolDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event: MutableSharedFlow = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun loadSchoolDetail(schoolId: Long, delayTimeMillis: Long) { + viewModelScope.launch { + val deferredSchoolInfo = + async { schoolRepository.loadSchoolInfo(schoolId, delayTimeMillis) } + val deferredFestivalPage = async { + schoolRepository.loadSchoolFestivals(schoolId = schoolId, size = FESTIVAL_PAGE_SIZE) + } + + runCatching { + val schoolInfo = deferredSchoolInfo.await().getOrThrow() + val festivalPage = deferredFestivalPage.await().getOrThrow() + + val isBookmarked = if (userRepository.isSigned()) { + bookmarkRepository.isBookmarked(schoolId, BookmarkType.SCHOOL) + } else { + false + } + + _uiState.value = SchoolDetailUiState.Success( + schoolInfo = schoolInfo, + bookmarked = isBookmarked, + festivals = festivalPage.festivals.map { it.toUiState() }, + isLast = festivalPage.isLastPage, + onBookmarkClick = { schoolId -> toggleSchoolBookmark(schoolId) }, + ) + if (!festivalPage.isLastPage) { + return@launch + } + val lastFestival = festivalPage.festivals.lastOrNull() + if (lastFestival == null || lastFestival.endDate >= LocalDate.now()) { + loadMoreSchoolFestivals(schoolId) + return@launch + } + }.onFailure { + handleFailure(key = KEY_LOAD_SCHOOL_DETAIL, throwable = it) + } + } + } + + fun loadMoreSchoolFestivals(schoolId: Long) { + val successUiState = uiState.value as? SchoolDetailUiState.Success ?: return + + viewModelScope.launch { + val currentFestivals = successUiState.festivals + val lastItem = currentFestivals.lastOrNull() + val isPast = + lastItem?.endDate?.isBefore(LocalDate.now()) ?: true || successUiState.isLast + val lastFestivalId = if (successUiState.isLast) null else lastItem?.id + val lastStartDate = if (successUiState.isLast) null else lastItem?.startDate + schoolRepository.loadSchoolFestivals( + schoolId = schoolId, + lastFestivalId = lastFestivalId?.toInt(), + lastStartDate = lastStartDate, + isPast = isPast, + size = FESTIVAL_PAGE_SIZE, + ).onSuccess { festivalsPage -> + _uiState.value = successUiState.copy( + festivals = currentFestivals + festivalsPage.festivals.map { it.toUiState() }, + isLast = festivalsPage.isLastPage, + ) + }.onFailure { + handleFailure(key = KEY_LOAD_MORE_SCHOOL_FESTIVALS, throwable = it) + } + } + } + + private fun toggleSchoolBookmark(schoolId: Int) { + val uiState = uiState.value as? SchoolDetailUiState.Success ?: return + + viewModelScope.launch { + if (uiState.bookmarked) { + _uiState.value = uiState.copy(bookmarked = false) + bookmarkRepository.deleteSchoolBookmark(schoolId.toLong()) + .onSuccess { _event.emit(BookmarkSuccess(false)) } + .onFailure { + if (it.isUnauthorized()) { + _event.emit(BookmarkFailure("로그인이 필요해요")) + } + if (it.isNetworkError()) { + _uiState.value = uiState.copy(bookmarked = true) + _event.emit(BookmarkFailure("인터넷 연결을 확인해주세요")) + } + } + } else { + _uiState.value = uiState.copy(bookmarked = true) + bookmarkRepository.addSchoolBookmark(uiState.schoolInfo.id.toLong()) + .onSuccess { _event.emit(BookmarkSuccess(true)) } + .onFailure { + if (it.isUnauthorized()) { + _event.emit(BookmarkFailure("로그인이 필요해요")) + } + if (it.isBookmarkLimitExceeded()) { + _uiState.value = uiState.copy(bookmarked = false) + _event.emit(BookmarkFailure("북마크는 12개까지 가능해요")) + } + if (it.isNetworkError()) { + _uiState.value = uiState.copy(bookmarked = false) + _event.emit(BookmarkFailure("인터넷 연결을 확인해주세요")) + } + } + } + } + } + + private fun handleFailure(key: String, throwable: Throwable) { + _uiState.value = SchoolDetailUiState.Error { + _uiState.value = SchoolDetailUiState.Loading + loadSchoolDetail(it, 0L) + } + analyticsHelper.logNetworkFailure( + key = key, + value = throwable.message.toString(), + ) + } + + private fun Festival.toUiState() = FestivalItemUiState( + id = id, + name = name, + startDate = startDate, + endDate = endDate, + imageUrl = imageUrl, + artists = artists.map { artist -> + ArtistUiState( + id = artist.id, + name = artist.name, + imageUrl = artist.imageUrl, + onArtistDetailClick = { artistUiState -> + viewModelScope.launch { + _event.emit(ShowArtistDetail(artistUiState)) + } + }, + ) + }, + onFestivalDetailClick = { festivalUiState -> + viewModelScope.launch { + _event.emit(ShowFestivalDetail(festivalUiState)) + } + }, + ) + + companion object { + private const val FESTIVAL_PAGE_SIZE = 20 + private const val KEY_LOAD_SCHOOL_DETAIL = "KEY_LOAD_SCHOOL_DETAIL" + private const val KEY_LOAD_MORE_SCHOOL_FESTIVALS = "KEY_LOAD_MORE_SCHOOL_FESTIVALS" + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolFestivalListAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolFestivalListAdapter.kt new file mode 100644 index 000000000..d93c08ec0 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/SchoolFestivalListAdapter.kt @@ -0,0 +1,56 @@ +package com.festago.festago.presentation.ui.schooldetail + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.schooldetail.uistate.FestivalItemUiState +import com.festago.festago.presentation.ui.schooldetail.uistate.MoreItemUiState + +class SchoolFestivalListAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): SchoolDetailViewHolder { + return when (viewType) { + 1 -> SchoolDetailFestivalViewHolder.of(parent) + 2 -> SchoolDetailMoreItemViewHolder.of(parent) + else -> throw IllegalArgumentException("Invalid viewType") + } + } + + override fun onBindViewHolder(holder: SchoolDetailViewHolder, position: Int) { + val item = getItem(position) + return when (holder) { + is SchoolDetailFestivalViewHolder -> holder.bind(item as FestivalItemUiState) + is SchoolDetailMoreItemViewHolder -> holder.bind(item as MoreItemUiState) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is FestivalItemUiState -> 1 + is MoreItemUiState -> 2 + else -> throw IllegalArgumentException("Invalid item") + } + } + + companion object { + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean = when { + oldItem is FestivalItemUiState && newItem is FestivalItemUiState -> oldItem.id == newItem.id + oldItem is MoreItemUiState && newItem is MoreItemUiState -> true + else -> false + } + + override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean = when { + oldItem is FestivalItemUiState && newItem is FestivalItemUiState + -> oldItem as FestivalItemUiState == newItem + + oldItem is MoreItemUiState && newItem is MoreItemUiState -> true + + else -> false + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/ArtistUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/ArtistUiState.kt new file mode 100644 index 000000000..feda05b6e --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/ArtistUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.schooldetail.uistate + +data class ArtistUiState( + val id: Long, + val name: String, + val imageUrl: String, + val onArtistDetailClick: (ArtistUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/FestivalItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/FestivalItemUiState.kt new file mode 100644 index 000000000..beb76e093 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/FestivalItemUiState.kt @@ -0,0 +1,13 @@ +package com.festago.festago.presentation.ui.schooldetail.uistate + +import java.time.LocalDate + +data class FestivalItemUiState( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val imageUrl: String, + val artists: List, + val onFestivalDetailClick: (FestivalItemUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/MoreItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/MoreItemUiState.kt new file mode 100644 index 000000000..f71f0b6ab --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/MoreItemUiState.kt @@ -0,0 +1,3 @@ +package com.festago.festago.presentation.ui.schooldetail.uistate + +data class MoreItemUiState(val requestMore: () -> Unit) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolDetailUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolDetailUiState.kt new file mode 100644 index 000000000..2c85eefad --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolDetailUiState.kt @@ -0,0 +1,22 @@ +package com.festago.festago.presentation.ui.schooldetail.uistate + +import com.festago.festago.domain.model.school.SchoolInfo + +sealed interface SchoolDetailUiState { + object Loading : SchoolDetailUiState + + data class Success( + val schoolInfo: SchoolInfo, + val bookmarked: Boolean, + val festivals: List, + val isLast: Boolean, + val onBookmarkClick: (Int) -> Unit, + ) : SchoolDetailUiState + + class Error(val refresh: (schoolId: Long) -> Unit) : SchoolDetailUiState + + val shouldShowSuccess get() = this is Success + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error + val shouldShowEmptyFestivals get() = this is Success && festivals.isEmpty() +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolUiState.kt new file mode 100644 index 000000000..720a57334 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/schooldetail/uistate/SchoolUiState.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.schooldetail.uistate + +data class SchoolUiState( + val id: Long, + val name: String, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchEvent.kt new file mode 100644 index 000000000..dd18d3240 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchEvent.kt @@ -0,0 +1,13 @@ +package com.festago.festago.presentation.ui.search + +import com.festago.festago.presentation.ui.search.uistate.ArtistUiState +import com.festago.festago.presentation.ui.search.uistate.FestivalSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.SchoolSearchItemUiState + +sealed interface SearchEvent { + class ShowFestivalDetail(val festival: FestivalSearchItemUiState) : SearchEvent + class ShowArtistDetail(val artist: ArtistUiState) : SearchEvent + class ShowSchoolDetail(val school: SchoolSearchItemUiState) : SearchEvent + class UpdateSearchQuery(val searchQuery: String) : SearchEvent + object SearchBlank : SearchEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchFragment.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchFragment.kt new file mode 100644 index 000000000..21807d625 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchFragment.kt @@ -0,0 +1,231 @@ +package com.festago.festago.presentation.ui.search + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnNextLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.FragmentSearchBinding +import com.festago.festago.presentation.ui.artistdetail.ArtistDetailArgs +import com.festago.festago.presentation.ui.festivaldetail.FestivalDetailArgs +import com.festago.festago.presentation.ui.schooldetail.SchoolDetailArgs +import com.festago.festago.presentation.ui.search.recentsearch.RecentSearchAdapter +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState.ArtistSearchScreen +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState.FestivalSearchScreen +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState.SchoolSearchScreen +import com.festago.festago.presentation.ui.search.screen.SearchScreenAdapter +import com.festago.festago.presentation.ui.search.uistate.SearchUiState +import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.setOnApplyWindowInsetsCompatListener +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SearchFragment : Fragment() { + + private var _binding: FragmentSearchBinding? = null + private val binding get() = _binding!! + + private val vm: SearchViewModel by viewModels() + + private lateinit var recentSearchAdapter: RecentSearchAdapter + private lateinit var searchScreenAdapter: SearchScreenAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentSearchBinding.inflate(inflater) + binding.root.setOnApplyWindowInsetsCompatListener { view, windowInsets -> + val statusBarInsets = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + view.setPadding(0, statusBarInsets.top, 0, 0) + windowInsets + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initObserve() + initView() + } + + private fun initObserve() { + repeatOnStarted(viewLifecycleOwner) { + vm.uiState.collect { + binding.uiState = it + updateUi(it) + } + } + repeatOnStarted(viewLifecycleOwner) { + vm.event.collect { + handleEvent(it) + } + } + } + + private fun updateUi(uiState: SearchUiState) { + when (uiState) { + is SearchUiState.Loading -> Unit + is SearchUiState.RecentSearchSuccess -> handleRecentSearchSuccess(uiState) + is SearchUiState.SearchSuccess -> handleSuccessSearch(uiState) + is SearchUiState.Error -> handleError(uiState) + } + } + + private fun initView() { + initRecyclerView() + initBack() + initSearch() + initDeleteAll() + initViewPager() + } + + private fun initViewPager() { + searchScreenAdapter = SearchScreenAdapter() + binding.vpSearch.adapter = searchScreenAdapter + TabLayoutMediator(binding.tlSearch, binding.vpSearch) { tab, position -> + tab.text = when (position) { + ItemSearchScreenUiState.FESTIVAL_POSITION -> getString(R.string.search_tl_tab_festival) + ItemSearchScreenUiState.ARTIST_POSITION -> getString(R.string.search_tl_tab_Artist) + ItemSearchScreenUiState.SCHOOL_POSITION -> getString(R.string.search_tl_tab_school) + else -> "" + } + tab.view.setOnClickListener { + val currentScreen = when (tab.position) { + ItemSearchScreenUiState.FESTIVAL_POSITION -> FestivalSearchScreen(listOf()) + ItemSearchScreenUiState.ARTIST_POSITION -> ArtistSearchScreen(listOf()) + ItemSearchScreenUiState.SCHOOL_POSITION -> SchoolSearchScreen(listOf()) + else -> FestivalSearchScreen(listOf()) + } + vm.currentScreen = currentScreen + } + }.attach() + } + + private fun initRecyclerView() { + recentSearchAdapter = RecentSearchAdapter() + binding.rvRecentSearch.adapter = recentSearchAdapter + binding.rvRecentSearch.itemAnimator = null + } + + private fun initBack() { + binding.ivBack.setOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + private fun initDeleteAll() { + binding.tvDeleteAll.setOnClickListener { + vm.deleteAllRecentSearch() + } + } + + private fun initSearch() { + binding.etSearch.setOnKeyListener { editText, keyCode, event -> + if ((event.action == KeyEvent.ACTION_DOWN) && (keyCode == KeyEvent.KEYCODE_ENTER)) { + vm.search(binding.etSearch.text.toString()) + val inputMethodManager = + context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(editText.windowToken, 0) + true + } else { + false + } + } + vm.loadRecentSearch() + } + + private fun handleRecentSearchSuccess(uiState: SearchUiState.RecentSearchSuccess) { + recentSearchAdapter.submitList(uiState.recentSearchQueries) + } + + private fun handleSuccessSearch(uiState: SearchUiState.SearchSuccess) { + searchScreenAdapter.submitList( + listOf( + FestivalSearchScreen(uiState.searchedFestivals, ::requestAddSearchQuery), + ArtistSearchScreen(uiState.searchedArtists, ::requestAddSearchQuery), + SchoolSearchScreen(uiState.searchedSchools, ::requestAddSearchQuery), + ), + ) + initSearchTab() + } + + private fun handleError(uiState: SearchUiState.Error) { + binding.refreshListener = { uiState.refresh.invoke(binding.etSearch.text.toString()) } + } + + private fun initSearchTab() { + binding.vpSearch.doOnNextLayout { + binding.vpSearch.setCurrentItem(vm.currentScreen.screenPosition, false) + } + } + + private fun handleEvent(event: SearchEvent) { + when (event) { + is SearchEvent.ShowFestivalDetail -> { + findNavController().navigate( + SearchFragmentDirections.actionSearchFragmentToFestivalDetailFragment( + with(event.festival) { FestivalDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is SearchEvent.ShowArtistDetail -> { + findNavController().navigate( + SearchFragmentDirections.actionSearchFragmentToArtistDetailFragment( + with(event.artist) { ArtistDetailArgs(id, name, imageUrl) }, + ), + ) + } + + is SearchEvent.ShowSchoolDetail -> { + findNavController().navigate( + SearchFragmentDirections.actionSearchFragmentToSchoolDetailFragment( + with(event.school) { SchoolDetailArgs(id, name, logoUrl) }, + ), + ) + } + + is SearchEvent.SearchBlank -> { + Toast.makeText( + requireContext(), + getString(R.string.search_cant_search_blank), + Toast.LENGTH_SHORT, + ).show() + } + + is SearchEvent.UpdateSearchQuery -> { + binding.etSearch.setText(event.searchQuery) + } + } + } + + private fun requestAddSearchQuery() { + startBrowser("https://forms.gle/y17dmCFw1jAYLR9H7") + } + + private fun startBrowser(url: String) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchViewModel.kt new file mode 100644 index 000000000..16af2d555 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/SearchViewModel.kt @@ -0,0 +1,168 @@ +package com.festago.festago.presentation.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.domain.model.recentsearch.RecentSearchQuery +import com.festago.festago.domain.model.search.ArtistSearch +import com.festago.festago.domain.model.search.FestivalSearch +import com.festago.festago.domain.model.search.SchoolSearch +import com.festago.festago.domain.repository.RecentSearchRepository +import com.festago.festago.domain.repository.SearchRepository +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState +import com.festago.festago.presentation.ui.search.screen.ItemSearchScreenUiState.FestivalSearchScreen +import com.festago.festago.presentation.ui.search.uistate.ArtistSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.ArtistUiState +import com.festago.festago.presentation.ui.search.uistate.FestivalSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.RecentSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.SchoolSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.SearchUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val recentSearchRepository: RecentSearchRepository, + private val searchRepository: SearchRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _uiState: MutableStateFlow = + MutableStateFlow(SearchUiState.RecentSearchSuccess(listOf())) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + var currentScreen: ItemSearchScreenUiState = FestivalSearchScreen(listOf()) + + fun loadRecentSearch() { + if (uiState.value is SearchUiState.SearchSuccess) return + viewModelScope.launch { + _uiState.value = SearchUiState.RecentSearchSuccess( + recentSearchRepository.getRecentSearchQueries(10).map { it.toUiState() }, + ) + } + } + + fun search(searchQuery: String) { + viewModelScope.launch { + if (searchQuery == "") { + _event.emit(SearchEvent.SearchBlank) + return@launch + } + _uiState.value = SearchUiState.Loading + recentSearchRepository.insertOrReplaceRecentSearch(searchQuery) + val deferredFestivals = async { searchRepository.searchFestivals(searchQuery) } + val deferredArtist = async { searchRepository.searchArtists(searchQuery) } + val deferredSchools = async { searchRepository.searchSchools(searchQuery) } + runCatching { + _uiState.value = SearchUiState.SearchSuccess( + deferredFestivals.await().getOrThrow().map { it.toUiState() }, + deferredArtist.await().getOrThrow().map { it.toUiState() }, + deferredSchools.await().getOrThrow().map { it.toUiState() }, + ) + }.onFailure { + _uiState.value = SearchUiState.Error { searchQuery -> + _uiState.value = SearchUiState.Loading + search(searchQuery) + } + analyticsHelper.logNetworkFailure( + key = KEY_SEARCH, + value = it.message.toString(), + ) + } + } + } + + private fun deleteRecentSearch(searchQuery: String) { + viewModelScope.launch { + recentSearchRepository.deleteRecentSearch(searchQuery) + loadRecentSearch() + } + } + + fun deleteAllRecentSearch() { + viewModelScope.launch { + recentSearchRepository.clearRecentSearches() + loadRecentSearch() + } + } + + private fun RecentSearchQuery.toUiState() = RecentSearchItemUiState( + recentQuery = query, + onQuerySearched = ::searchRecentQuery, + onRecentSearchDeleted = ::deleteRecentSearch, + ) + + private fun FestivalSearch.toUiState() = FestivalSearchItemUiState( + id = id, + name = name, + startDate = startDate, + endDate = endDate, + imageUrl = imageUrl, + artists = artists.map { artist -> + ArtistUiState(artist.id, artist.name, artist.imageUrl, ::showArtistDetail) + }, + ::showFestivalDetail, + ) + + private fun ArtistSearch.toUiState() = ArtistSearchItemUiState( + id = id, + name = name, + profileImageUrl = profileImageUrl, + todayStage = todayStage, + upcomingStage = upcomingStage, + onArtistDetailClick = ::showArtistDetail, + ) + + private fun SchoolSearch.toUiState() = SchoolSearchItemUiState( + id = id, + name = name, + logoUrl = logoUrl, + upcomingFestivalStartDate = upcomingFestivalStartDate, + onSchoolSearchClick = ::showSchoolDetail, + ) + + private fun showFestivalDetail(festival: FestivalSearchItemUiState) { + viewModelScope.launch { + _event.emit(SearchEvent.ShowFestivalDetail(festival)) + } + } + + private fun showArtistDetail(artist: ArtistUiState) { + viewModelScope.launch { + _event.emit(SearchEvent.ShowArtistDetail(artist)) + } + } + + private fun showSchoolDetail(school: SchoolSearchItemUiState) { + viewModelScope.launch { + _event.emit(SearchEvent.ShowSchoolDetail(school)) + } + } + + private fun searchRecentQuery(searchQuery: String) { + search(searchQuery) + updateSearchQuery(searchQuery) + } + + private fun updateSearchQuery(searchQuery: String) { + viewModelScope.launch { + _event.emit(SearchEvent.UpdateSearchQuery(searchQuery)) + } + } + + companion object { + private const val KEY_SEARCH = "KEY_SEARCH" + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchAdapter.kt new file mode 100644 index 000000000..49e677853 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchAdapter.kt @@ -0,0 +1,32 @@ +package com.festago.festago.presentation.ui.search.artistsearch + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.ArtistSearchItemUiState + +class ArtistSearchAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistSearchViewHolder { + return ArtistSearchViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistSearchViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistSearchItemUiState, + newItem: ArtistSearchItemUiState, + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: ArtistSearchItemUiState, + newItem: ArtistSearchItemUiState, + ): Boolean = oldItem == newItem + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchViewHolder.kt new file mode 100644 index 000000000..a3c7e6ff5 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/artistsearch/ArtistSearchViewHolder.kt @@ -0,0 +1,77 @@ +package com.festago.festago.presentation.ui.search.artistsearch + +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemSearchArtistBinding +import com.festago.festago.presentation.ui.search.uistate.ArtistSearchItemUiState + +class ArtistSearchViewHolder( + private val binding: ItemSearchArtistBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ArtistSearchItemUiState) { + binding.item = item + + if (item.todayStage == 0 && item.upcomingStage == 0) { + binding.tvTodayStageCount.visibility = TextView.GONE + binding.tvUpcomingStageCount.visibility = TextView.GONE + } else { + binding.tvTodayStageCount.setStageCount( + count = item.todayStage, + stringRes = R.string.search_artist_tv_today_stage_count, + ) + + binding.tvUpcomingStageCount.setStageCount( + item.upcomingStage, + stringRes = R.string.search_artist_tv_upcoming_stage_count, + ) + binding.tvEmptyStage.visibility = TextView.GONE + } + } + + private fun TextView.setStageCount(count: Int, @StringRes stringRes: Int) { + val stageCountText = context.getString(stringRes, count) + text = SpannableString(stageCountText).apply { + getPartialColorText( + start = COLOR_INDEX, + end = COLOR_INDEX + count.toString().length, + color = when (count) { + 0 -> context.getColor(R.color.contents_gray_05) + else -> context.getColor(R.color.secondary_pink_01) + }, + ) + } + if (count == 0) { + setTextColor(context.getColor(R.color.contents_gray_05)) + } + } + + private fun SpannableString.getPartialColorText( + start: Int, + end: Int, + @ColorInt color: Int, + ) { + setSpan(ForegroundColorSpan(color), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + companion object { + private const val COLOR_INDEX = 6 + + fun of(parent: ViewGroup): ArtistSearchViewHolder { + val binding = ItemSearchArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistSearchViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchAdapter.kt new file mode 100644 index 000000000..bcc748f03 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchAdapter.kt @@ -0,0 +1,33 @@ +package com.festago.festago.presentation.ui.search.festivalsearch + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.FestivalSearchItemUiState + +class FestivalSearchAdapter : + ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FestivalSearchViewHolder { + return FestivalSearchViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: FestivalSearchViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: FestivalSearchItemUiState, + newItem: FestivalSearchItemUiState, + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: FestivalSearchItemUiState, + newItem: FestivalSearchItemUiState, + ): Boolean = oldItem == newItem + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchViewHolder.kt new file mode 100644 index 000000000..3a3de1cdb --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/FestivalSearchViewHolder.kt @@ -0,0 +1,100 @@ +package com.festago.festago.presentation.ui.search.festivalsearch + +import android.content.res.Resources +import android.graphics.Rect +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemSearchFestivalBinding +import com.festago.festago.presentation.ui.search.festivalsearch.artist.ArtistAdapter +import com.festago.festago.presentation.ui.search.uistate.FestivalSearchItemUiState +import java.time.LocalDate + +class FestivalSearchViewHolder( + private val binding: ItemSearchFestivalBinding, +) : RecyclerView.ViewHolder(binding.root) { + + private val artistAdapter = ArtistAdapter() + + init { + binding.rvFestivalArtists.adapter = artistAdapter + binding.rvFestivalArtists.addItemDecoration(ArtistItemDecoration()) + binding.rvFestivalArtists.itemAnimator = null + } + + fun bind(item: FestivalSearchItemUiState) { + binding.item = item + artistAdapter.submitList(item.artists) + binding.tvEmptyStage.visibility = if (item.artists.isEmpty()) View.VISIBLE else View.GONE + binding.tvFestivalDDay.bindFestivalDday(item) + } + + private fun TextView.bindFestivalDday(item: FestivalSearchItemUiState) { + when { + LocalDate.now() > item.endDate -> { + binding.tvFestivalDDay.visibility = View.GONE + binding.tvFestivalDDayEnd.visibility = View.VISIBLE + } + + LocalDate.now() >= item.startDate -> { + text = context.getString(R.string.festival_list_tv_dday_in_progress) + setTextColor(context.getColor(R.color.secondary_pink_01)) + background = AppCompatResources.getDrawable( + context, + R.drawable.bg_festival_list_dday_in_progress, + ) + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + } + + else -> { + val dDay = LocalDate.now().toEpochDay() - item.startDate.toEpochDay() + val backgroundColor = if (dDay >= -7L) { + context.getColor(R.color.secondary_pink_01) + } else { + context.getColor(R.color.contents_gray_07) + } + setBackgroundColor(backgroundColor) + setTextColor(context.getColor(R.color.background_gray_01)) + text = context.getString(R.string.tv_dday_format, dDay.toString()) + binding.tvFestivalDDay.visibility = View.VISIBLE + binding.tvFestivalDDayEnd.visibility = View.GONE + } + } + } + + private class ArtistItemDecoration : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + outRect.right = 8.dpToPx + } + + private val Int.dpToPx: Int + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ).toInt() + } + + companion object { + fun of(parent: ViewGroup): FestivalSearchViewHolder { + val binding = ItemSearchFestivalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalSearchViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistAdapter.kt new file mode 100644 index 000000000..103758f3f --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistAdapter.kt @@ -0,0 +1,35 @@ +package com.festago.festago.presentation.ui.search.festivalsearch.artist + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.ArtistUiState + +class ArtistAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder { + return ArtistViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ArtistUiState, + newItem: ArtistUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistViewHolder.kt new file mode 100644 index 000000000..cb3f878a4 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/festivalsearch/artist/ArtistViewHolder.kt @@ -0,0 +1,27 @@ +package com.festago.festago.presentation.ui.search.festivalsearch.artist + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemSearchFestivalArtistBinding +import com.festago.festago.presentation.ui.search.uistate.ArtistUiState + +class ArtistViewHolder( + private val binding: ItemSearchFestivalArtistBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ArtistUiState) { + binding.item = item + } + + companion object { + fun of(parent: ViewGroup): ArtistViewHolder { + val binding = ItemSearchFestivalArtistBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchAdapter.kt new file mode 100644 index 000000000..03721096e --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchAdapter.kt @@ -0,0 +1,36 @@ +package com.festago.festago.presentation.ui.search.recentsearch + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.RecentSearchItemUiState + +class RecentSearchAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecentSearchViewHolder { + return RecentSearchViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: RecentSearchViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: RecentSearchItemUiState, + newItem: RecentSearchItemUiState, + ): Boolean { + return oldItem.recentQuery == newItem.recentQuery + } + + override fun areContentsTheSame( + oldItem: RecentSearchItemUiState, + newItem: RecentSearchItemUiState, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchViewHolder.kt new file mode 100644 index 000000000..1f76330ca --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/recentsearch/RecentSearchViewHolder.kt @@ -0,0 +1,27 @@ +package com.festago.festago.presentation.ui.search.recentsearch + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.databinding.ItemRecentSearchBinding +import com.festago.festago.presentation.ui.search.uistate.RecentSearchItemUiState + +class RecentSearchViewHolder( + val binding: ItemRecentSearchBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: RecentSearchItemUiState) { + binding.item = item + } + + companion object { + fun of(parent: ViewGroup): RecentSearchViewHolder { + val binding = ItemRecentSearchBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return RecentSearchViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchAdapter.kt new file mode 100644 index 000000000..2735e3d71 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchAdapter.kt @@ -0,0 +1,32 @@ +package com.festago.festago.presentation.ui.search.schoolSearchAdatper + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.festago.festago.presentation.ui.search.uistate.SchoolSearchItemUiState + +class SchoolSearchAdapter : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SchoolSearchViewHolder { + return SchoolSearchViewHolder.of(parent) + } + + override fun onBindViewHolder(holder: SchoolSearchViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: SchoolSearchItemUiState, + newItem: SchoolSearchItemUiState, + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: SchoolSearchItemUiState, + newItem: SchoolSearchItemUiState, + ): Boolean = oldItem == newItem + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchViewHolder.kt new file mode 100644 index 000000000..26d3b37e4 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/schoolSearchAdatper/SchoolSearchViewHolder.kt @@ -0,0 +1,56 @@ +package com.festago.festago.presentation.ui.search.schoolSearchAdatper + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ItemSearchSchoolBinding +import com.festago.festago.presentation.ui.search.uistate.SchoolSearchItemUiState +import java.time.LocalDate + +class SchoolSearchViewHolder( + private val binding: ItemSearchSchoolBinding, +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: SchoolSearchItemUiState) { + binding.item = item + binding.tvSchoolFestivalDday.setSchoolFestivalDday(item.upcomingFestivalStartDate) + } + + private fun TextView.setSchoolFestivalDday( + upcomingFestivalStartDate: LocalDate?, + ) { + when { + upcomingFestivalStartDate == null -> { + text = context.getString(R.string.search_school_tv_no_plan) + setTextColor(context.getColor(R.color.contents_gray_05)) + } + + LocalDate.now() >= upcomingFestivalStartDate -> { + text = context.getString(R.string.search_school_tv_dday_in_progress) + setTextColor(context.getColor(R.color.secondary_pink_01)) + } + + LocalDate.now() < upcomingFestivalStartDate -> { + val dDay = + LocalDate.now().toEpochDay() - upcomingFestivalStartDate.toEpochDay() + text = context.getString(R.string.search_school_tv_dday_format, dDay.toString()) + val colorId = + if (dDay >= -7L) R.color.secondary_pink_01 else R.color.contents_gray_07 + setTextColor(context.getColor(colorId)) + } + } + } + + companion object { + fun of(parent: ViewGroup): SchoolSearchViewHolder { + val binding = ItemSearchSchoolBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return SchoolSearchViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ArtistSearchScreenViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ArtistSearchScreenViewHolder.kt new file mode 100644 index 000000000..fe9233a74 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ArtistSearchScreenViewHolder.kt @@ -0,0 +1,44 @@ +package com.festago.festago.presentation.ui.search.screen + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemArtistSearchScreenBinding +import com.festago.festago.presentation.ui.search.artistsearch.ArtistSearchAdapter + +class ArtistSearchScreenViewHolder( + private val binding: ItemArtistSearchScreenBinding, +) : SearchScreenViewHolder(binding) { + + private val artistSearchAdapter: ArtistSearchAdapter = ArtistSearchAdapter() + + init { + binding.rvArtists.adapter = artistSearchAdapter + } + + fun bind(item: ItemSearchScreenUiState.ArtistSearchScreen) { + artistSearchAdapter.submitList(item.artistSearches) + setNoResultVisibility(item) + binding.btnFestago.setOnClickListener { + item.onAddSearchQueryClick() + } + } + + private fun setNoResultVisibility(item: ItemSearchScreenUiState.ArtistSearchScreen) { + val visibility = if (item.artistSearches.isEmpty()) View.VISIBLE else View.GONE + binding.tvNoResult.visibility = visibility + binding.tvNoResultGuide.visibility = visibility + binding.btnFestago.visibility = visibility + } + + companion object { + fun of(parent: ViewGroup): ArtistSearchScreenViewHolder { + val binding = ItemArtistSearchScreenBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ArtistSearchScreenViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/FestivalSearchScreenViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/FestivalSearchScreenViewHolder.kt new file mode 100644 index 000000000..eb33d7c81 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/FestivalSearchScreenViewHolder.kt @@ -0,0 +1,44 @@ +package com.festago.festago.presentation.ui.search.screen + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemFestivalSearchScreenBinding +import com.festago.festago.presentation.ui.search.festivalsearch.FestivalSearchAdapter + +class FestivalSearchScreenViewHolder( + private val binding: ItemFestivalSearchScreenBinding, +) : SearchScreenViewHolder(binding) { + + private val festivalSearchAdapter: FestivalSearchAdapter = FestivalSearchAdapter() + + init { + binding.rvFestivals.adapter = festivalSearchAdapter + } + + fun bind(item: ItemSearchScreenUiState.FestivalSearchScreen) { + festivalSearchAdapter.submitList(item.festivalSearches) + setNoResultVisibility(item) + binding.btnFestago.setOnClickListener { + item.onAddSearchQueryClick() + } + } + + private fun setNoResultVisibility(item: ItemSearchScreenUiState.FestivalSearchScreen) { + val visibility = if (item.festivalSearches.isEmpty()) View.VISIBLE else View.GONE + binding.tvNoResult.visibility = visibility + binding.tvNoResultGuide.visibility = visibility + binding.btnFestago.visibility = visibility + } + + companion object { + fun of(parent: ViewGroup): FestivalSearchScreenViewHolder { + val binding = ItemFestivalSearchScreenBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return FestivalSearchScreenViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ItemSearchScreenUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ItemSearchScreenUiState.kt new file mode 100644 index 000000000..78a886270 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/ItemSearchScreenUiState.kt @@ -0,0 +1,30 @@ +package com.festago.festago.presentation.ui.search.screen + +import com.festago.festago.presentation.ui.search.uistate.ArtistSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.FestivalSearchItemUiState +import com.festago.festago.presentation.ui.search.uistate.SchoolSearchItemUiState + +sealed class ItemSearchScreenUiState( + val screenPosition: Int, +) { + data class FestivalSearchScreen( + val festivalSearches: List, + val onAddSearchQueryClick: () -> Unit = {}, + ) : ItemSearchScreenUiState(FESTIVAL_POSITION) + + data class ArtistSearchScreen( + val artistSearches: List, + val onAddSearchQueryClick: () -> Unit = {}, + ) : ItemSearchScreenUiState(ARTIST_POSITION) + + data class SchoolSearchScreen( + val schoolSearches: List, + val onAddSearchQueryClick: () -> Unit = {}, + ) : ItemSearchScreenUiState(SCHOOL_POSITION) + + companion object { + const val FESTIVAL_POSITION = 0 + const val ARTIST_POSITION = 1 + const val SCHOOL_POSITION = 2 + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SchoolSearchScreenViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SchoolSearchScreenViewHolder.kt new file mode 100644 index 000000000..8702fa879 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SchoolSearchScreenViewHolder.kt @@ -0,0 +1,42 @@ +package com.festago.festago.presentation.ui.search.screen + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.festago.festago.presentation.databinding.ItemSchoolSearchScreenBinding +import com.festago.festago.presentation.ui.search.schoolSearchAdatper.SchoolSearchAdapter + +class SchoolSearchScreenViewHolder(private val binding: ItemSchoolSearchScreenBinding) : + SearchScreenViewHolder(binding) { + private val schoolSearchAdapter: SchoolSearchAdapter = SchoolSearchAdapter() + + init { + binding.rvSchools.adapter = schoolSearchAdapter + } + + fun bind(item: ItemSearchScreenUiState.SchoolSearchScreen) { + schoolSearchAdapter.submitList(item.schoolSearches) + setNoResultVisibility(item) + binding.btnFestago.setOnClickListener { + item.onAddSearchQueryClick() + } + } + + private fun setNoResultVisibility(item: ItemSearchScreenUiState.SchoolSearchScreen) { + val visibility = if (item.schoolSearches.isEmpty()) View.VISIBLE else View.GONE + binding.tvNoResult.visibility = visibility + binding.tvNoResultGuide.visibility = visibility + binding.btnFestago.visibility = visibility + } + + companion object { + fun of(parent: ViewGroup): SchoolSearchScreenViewHolder { + val binding = ItemSchoolSearchScreenBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return SchoolSearchScreenViewHolder(binding) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenAdapter.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenAdapter.kt new file mode 100644 index 000000000..23c7781aa --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenAdapter.kt @@ -0,0 +1,47 @@ +package com.festago.festago.presentation.ui.search.screen + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter + +class SearchScreenAdapter : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchScreenViewHolder { + return when (viewType) { + 1 -> FestivalSearchScreenViewHolder.of(parent) + 2 -> ArtistSearchScreenViewHolder.of(parent) + 3 -> SchoolSearchScreenViewHolder.of(parent) + else -> throw IllegalArgumentException("Invalid viewType") + } + } + + override fun onBindViewHolder(holder: SearchScreenViewHolder, position: Int) { + val item = getItem(position) + return when (holder) { + is FestivalSearchScreenViewHolder -> holder.bind(item as ItemSearchScreenUiState.FestivalSearchScreen) + is ArtistSearchScreenViewHolder -> holder.bind(item as ItemSearchScreenUiState.ArtistSearchScreen) + is SchoolSearchScreenViewHolder -> holder.bind(item as ItemSearchScreenUiState.SchoolSearchScreen) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is ItemSearchScreenUiState.FestivalSearchScreen -> 1 + is ItemSearchScreenUiState.ArtistSearchScreen -> 2 + is ItemSearchScreenUiState.SchoolSearchScreen -> 3 + } + } + + companion object { + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ItemSearchScreenUiState, + newItem: ItemSearchScreenUiState, + ): Boolean = oldItem === newItem + + override fun areContentsTheSame( + oldItem: ItemSearchScreenUiState, + newItem: ItemSearchScreenUiState, + ): Boolean = oldItem == newItem + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenViewHolder.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenViewHolder.kt new file mode 100644 index 000000000..40df9c049 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/screen/SearchScreenViewHolder.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.search.screen + +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView + +sealed class SearchScreenViewHolder(binding: ViewDataBinding) : + RecyclerView.ViewHolder(binding.root) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistSearchItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistSearchItemUiState.kt new file mode 100644 index 000000000..38f73a110 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistSearchItemUiState.kt @@ -0,0 +1,17 @@ +package com.festago.festago.presentation.ui.search.uistate + +data class ArtistSearchItemUiState( + val id: Long, + val name: String, + val profileImageUrl: String, + val todayStage: Int, + val upcomingStage: Int, + val onArtistDetailClick: (ArtistUiState) -> Unit, +) { + fun toArtistUiState() = ArtistUiState( + id, + name, + profileImageUrl, + onArtistDetailClick, + ) +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistUiState.kt new file mode 100644 index 000000000..0759f0311 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/ArtistUiState.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.ui.search.uistate + +data class ArtistUiState( + val id: Long, + val name: String, + val imageUrl: String, + val onArtistDetailClick: (artist: ArtistUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/FestivalSearchItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/FestivalSearchItemUiState.kt new file mode 100644 index 000000000..1ee3e4fb2 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/FestivalSearchItemUiState.kt @@ -0,0 +1,13 @@ +package com.festago.festago.presentation.ui.search.uistate + +import java.time.LocalDate + +data class FestivalSearchItemUiState( + val id: Long, + val name: String, + val startDate: LocalDate, + val endDate: LocalDate, + val imageUrl: String, + val artists: List, + val onFestivalSearchClick: (festival: FestivalSearchItemUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/RecentSearchItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/RecentSearchItemUiState.kt new file mode 100644 index 000000000..72ad05d82 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/RecentSearchItemUiState.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.search.uistate + +data class RecentSearchItemUiState( + val recentQuery: String, + val onQuerySearched: (recentQuery: String) -> Unit, + val onRecentSearchDeleted: (recentQuery: String) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolSearchItemUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolSearchItemUiState.kt new file mode 100644 index 000000000..f3b064be7 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolSearchItemUiState.kt @@ -0,0 +1,11 @@ +package com.festago.festago.presentation.ui.search.uistate + +import java.time.LocalDate + +data class SchoolSearchItemUiState( + val id: Long, + val name: String, + val logoUrl: String, + val upcomingFestivalStartDate: LocalDate?, + val onSchoolSearchClick: (school: SchoolSearchItemUiState) -> Unit, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolUiState.kt new file mode 100644 index 000000000..446acb2ba --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SchoolUiState.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.search.uistate + +data class SchoolUiState( + val id: Long, + val name: String, +) diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SearchUiState.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SearchUiState.kt new file mode 100644 index 000000000..09b7e3956 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/search/uistate/SearchUiState.kt @@ -0,0 +1,24 @@ +package com.festago.festago.presentation.ui.search.uistate + +sealed interface SearchUiState { + object Loading : SearchUiState + + data class RecentSearchSuccess( + val recentSearchQueries: List, + ) : SearchUiState + + data class SearchSuccess( + val searchedFestivals: List, + val searchedArtists: List, + val searchedSchools: List, + ) : SearchUiState + + class Error(val refresh: (searchQuery: String) -> Unit) : SearchUiState + + val shouldShowNotEmptyRecentSearchSuccess get() = this is RecentSearchSuccess && recentSearchQueries.isNotEmpty() + val shouldShowEmptyRecentSearchSuccess get() = this is RecentSearchSuccess && recentSearchQueries.isEmpty() + val shouldShowRecentSearchSuccess get() = this is RecentSearchSuccess + val shouldShowSearchSuccess get() = this is SearchSuccess + val shouldShowLoading get() = this is Loading + val shouldShowError get() = this is Error +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInActivity.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInActivity.kt new file mode 100644 index 000000000..fa0469996 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInActivity.kt @@ -0,0 +1,120 @@ +package com.festago.festago.presentation.ui.signin + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import com.festago.festago.common.kakao.KakaoAuthorization +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ActivitySignInBinding +import com.festago.festago.presentation.ui.home.HomeActivity +import com.festago.festago.presentation.util.repeatOnStarted +import com.festago.festago.presentation.util.setOnApplyWindowInsetsCompatListener +import com.festago.festago.presentation.util.setStatusBarMode +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class SignInActivity : AppCompatActivity() { + + private val binding: ActivitySignInBinding by lazy { + ActivitySignInBinding.inflate(layoutInflater) + } + + @Inject + lateinit var kakaoAuthorization: KakaoAuthorization + + private val vm: SignInViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initView() + initObserve() + setContentView(binding.root) + } + + private fun initView() { + initWindowInsets() + initKakaoLogin() + initWithoutLogin() + } + + private fun initObserve() { + repeatOnStarted(this) { + vm.event.collect { + when (it) { + SignInEvent.SignInSuccess -> handleSignInSuccess() + SignInEvent.ShowHome -> navigateToHome() + SignInEvent.SignInFailure -> handleSignInFailure() + } + } + } + } + + private fun initWindowInsets() { + WindowCompat.setDecorFitsSystemWindows(window, false) + binding.root.setOnApplyWindowInsetsCompatListener { view, windowInsets -> + val navigationInsets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) + view.setPadding(0, 0, 0, navigationInsets.bottom) + window.navigationBarColor = Color.TRANSPARENT + window.statusBarColor = Color.TRANSPARENT + setStatusBarMode(isLight = false, backgroundColor = Color.TRANSPARENT) + windowInsets + } + } + + private fun initKakaoLogin() { + binding.btnKakaoLogin.setOnClickListener { + lifecycleScope.launch { + kakaoAuthorization.getIdToken(this@SignInActivity) + .onSuccess { idToken -> + vm.signIn(idToken) + }.onFailure { error -> + handleSignInFailure() + } + } + } + } + + private fun initWithoutLogin() { + binding.tvWithoutLogin.setOnClickListener { + vm.rejectSignIn() + } + } + + private fun handleSignInSuccess() { + Toast.makeText( + this, + getString(R.string.sign_in_success), + Toast.LENGTH_SHORT, + ).show() + navigateToHome() + } + + private fun navigateToHome() { + finishAffinity() + startActivity(HomeActivity.getIntent(this)) + } + + private fun handleSignInFailure() { + Toast.makeText( + this, + getString(R.string.sign_in_default_error_message), + Toast.LENGTH_SHORT, + ).show() + } + + companion object { + + fun getIntent(context: Context): Intent { + return Intent(context, SignInActivity::class.java) + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt new file mode 100644 index 000000000..7edc14094 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInEvent.kt @@ -0,0 +1,7 @@ +package com.festago.festago.presentation.ui.signin + +sealed interface SignInEvent { + object ShowHome : SignInEvent + object SignInSuccess : SignInEvent + object SignInFailure : SignInEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt new file mode 100644 index 000000000..9020ab178 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/signin/SignInViewModel.kt @@ -0,0 +1,49 @@ +package com.festago.festago.presentation.ui.signin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.common.analytics.AnalyticsHelper +import com.festago.festago.common.analytics.logNetworkFailure +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SignInViewModel @Inject constructor( + private val userRepository: UserRepository, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + + private val _event: MutableSharedFlow = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun signIn(idToken: String) { + viewModelScope.launch { + userRepository.signIn(idToken) + .onSuccess { + _event.emit(SignInEvent.SignInSuccess) + }.onFailure { + _event.emit(SignInEvent.SignInFailure) + analyticsHelper.logNetworkFailure( + key = KEY_SIGN_IN, + value = it.message.toString(), + ) + } + } + } + + fun rejectSignIn() { + viewModelScope.launch { + userRepository.rejectSignIn() + _event.emit(SignInEvent.ShowHome) + } + } + + companion object { + private const val KEY_SIGN_IN = "KEY_SIGN_IN" + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt new file mode 100644 index 000000000..1f3da7b10 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashActivity.kt @@ -0,0 +1,208 @@ +package com.festago.festago.presentation.ui.splash + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.core.splashscreen.SplashScreen +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.festago.festago.presentation.BuildConfig +import com.festago.festago.presentation.R +import com.festago.festago.presentation.databinding.ActivitySplashBinding +import com.festago.festago.presentation.ui.home.HomeActivity +import com.festago.festago.presentation.ui.signin.SignInActivity +import com.festago.festago.presentation.util.repeatOnStarted +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings +import com.kakao.sdk.common.KakaoSdk +import dagger.hilt.android.AndroidEntryPoint + +@SuppressLint("CustomSplashScreen") +@AndroidEntryPoint +class SplashActivity : ComponentActivity() { + + private val binding by lazy { ActivitySplashBinding.inflate(layoutInflater) } + private val firebaseRemoteConfig by lazy { FirebaseRemoteConfig.getInstance() } + private lateinit var splashScreen: SplashScreen + private val configSettings by lazy { + FirebaseRemoteConfigSettings.Builder() + .setMinimumFetchIntervalInSeconds(if (BuildConfig.DEBUG) DEBUG_REMOTE_CONFIG_FETCH_INTERVAL else RELEASE_REMOTE_CONFIG_FETCH_INTERVAL) + .build() + } + private val vm: SplashViewModel by viewModels() + + init { + firebaseRemoteConfig.setConfigSettingsAsync(configSettings) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initSplashScreen() + initKakaoSdk() + initObserve() + setContentView(binding.root) + } + + override fun onResume() { + super.onResume() + checkAppUpdate() + } + + private fun initSplashScreen() { + splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { true } + } + + private fun initKakaoSdk() { + KakaoSdk.init(this.applicationContext, BuildConfig.KAKAO_NATIVE_APP_KEY) + } + + private fun initObserve() { + repeatOnStarted(this) { + vm.event.collect { + when (it) { + is SplashEvent.ShowHome -> navigateToHome() + is SplashEvent.ShowSignIn -> navigateToSignIn() + } + } + } + } + + private fun checkAppUpdate() { + firebaseRemoteConfig.fetchAndActivate().addOnCompleteListener(this) { + if (it.isSuccessful) { + val currentVersion = try { + packageManager.getPackageInfo(packageName, 0).longVersionCode + } catch (e: Exception) { + e.printStackTrace() + handleError() + return@addOnCompleteListener + } + val latestVersion = firebaseRemoteConfig.getLong(KEY_LATEST_VERSION) + if (latestVersion <= currentVersion) { + vm.checkSignIn() + return@addOnCompleteListener + } + splashScreen.setKeepOnScreenCondition { false } + requestUpdate(latestVersion) + } else { + handleError() + } + } + } + + private fun requestUpdate(latestVersion: Long) { + val latestVersionDescription = + firebaseRemoteConfig.getString(KEY_LATEST_VERSION_DESCRIPTION) + + if (checkForceUpdate(latestVersionDescription)) return + checkOptionalUpdate(latestVersion, latestVersionDescription) + } + + private fun checkForceUpdate(latestVersionDescription: String): Boolean { + val isForceUpdateVersion = firebaseRemoteConfig.getBoolean(KEY_FORCE_UPDATE_REQUIRED) + if (isForceUpdateVersion) { + requestForceUpdate(message = latestVersionDescription) + return true + } + return false + } + + private fun checkOptionalUpdate(latestVersion: Long, latestVersionDescription: String) { + val isOptionalUpdateVersion = firebaseRemoteConfig.getBoolean(KEY_OPTIONAL_UPDATE_REQUIRED) + if (isOptionalUpdateVersion) { + requestOptionalUpdate(latestVersion = latestVersion, message = latestVersionDescription) + } + } + + private fun requestForceUpdate(message: String) { + alertUpdate(message = message, update = ::handleUpdate, cancel = ::handleCancelForceUpdate) + } + + private fun requestOptionalUpdate(latestVersion: Long, message: String) { + val sharedPref = getPreferences(MODE_PRIVATE) ?: return + val storedLatestVersion = sharedPref.getLong(KEY_STORED_LATEST_VERSION, 0L) + if (latestVersion == storedLatestVersion) { + vm.checkSignIn() + return + } + alertUpdate(message = message, update = ::handleUpdate) { + handleOptionalUpdateCancel(sharedPref, latestVersion) + } + } + + private fun handleOptionalUpdateCancel(sharedPref: SharedPreferences, latestVersion: Long) { + updateStoredLatestVersion(sharedPref, latestVersion) + vm.checkSignIn() + } + + private fun updateStoredLatestVersion(sharedPref: SharedPreferences, latestVersion: Long) { + with(sharedPref.edit()) { + putLong(KEY_STORED_LATEST_VERSION, latestVersion) + apply() + } + } + + private fun handleUpdate() { + navigateToAppStore() + finish() + } + + private fun navigateToHome() { + startActivity(HomeActivity.getIntent(this)) + finish() + } + + private fun navigateToSignIn() { + startActivity(SignInActivity.getIntent(this)) + finish() + } + + private fun navigateToAppStore() { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName"))) + finish() + } + + private fun handleError() { + Toast.makeText( + this@SplashActivity, + getString(R.string.splash_app_default_error_message), + Toast.LENGTH_SHORT, + ).show() + finish() + } + + private fun alertUpdate(message: String, update: () -> Unit, cancel: () -> Unit) { + AlertDialog.Builder(this).apply { + setTitle(getString(R.string.splash_app_update_request_dialog_title)) + setMessage(message) + setPositiveButton(R.string.ok_dialog_btn_update) { _, _ -> update() } + setNegativeButton(R.string.ok_dialog_btn_cancel) { _, _ -> cancel() } + setCancelable(false) + }.show() + } + + private fun handleCancelForceUpdate() { + Toast.makeText( + this@SplashActivity, + getString(R.string.splash_app_update_denied), + Toast.LENGTH_SHORT, + ).show() + finish() + } + + companion object { + private const val KEY_STORED_LATEST_VERSION = "KEY_STORED_LATEST_VERSION" + private const val DEBUG_REMOTE_CONFIG_FETCH_INTERVAL = 0L + private const val RELEASE_REMOTE_CONFIG_FETCH_INTERVAL = 3600L + private const val KEY_FORCE_UPDATE_REQUIRED = "FORCE_UPDATE_REQUIRED" + private const val KEY_OPTIONAL_UPDATE_REQUIRED = "OPTIONAL_UPDATE_REQUIRED" + private const val KEY_LATEST_VERSION = "LATEST_VERSION" + private const val KEY_LATEST_VERSION_DESCRIPTION = "LATEST_VERSION_DESCRIPTION" + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashEvent.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashEvent.kt new file mode 100644 index 000000000..718ecf3bf --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashEvent.kt @@ -0,0 +1,6 @@ +package com.festago.festago.presentation.ui.splash + +sealed interface SplashEvent { + object ShowSignIn : SplashEvent + object ShowHome : SplashEvent +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashViewModel.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashViewModel.kt new file mode 100644 index 000000000..f0775d768 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/ui/splash/SplashViewModel.kt @@ -0,0 +1,30 @@ +package com.festago.festago.presentation.ui.splash + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.festago.festago.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + private val userRepository: UserRepository, +) : ViewModel() { + + private val _event: MutableSharedFlow = MutableSharedFlow() + val event: SharedFlow = _event.asSharedFlow() + + fun checkSignIn() { + viewModelScope.launch { + when { + userRepository.isSigned() -> _event.emit(SplashEvent.ShowHome) + userRepository.isSignRejected() -> _event.emit(SplashEvent.ShowHome) + else -> _event.emit(SplashEvent.ShowSignIn) + } + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/ActivityUtil.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/ActivityUtil.kt new file mode 100644 index 000000000..83d2f08fc --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/ActivityUtil.kt @@ -0,0 +1,35 @@ +package com.festago.festago.presentation.util + +import android.app.Activity +import android.graphics.Color +import android.view.View +import android.view.WindowInsetsController +import androidx.core.content.ContextCompat +import com.festago.festago.presentation.R + +@Suppress("DEPRECATION") +fun Activity.setStatusBarMode( + isLight: Boolean, + backgroundColor: Int?, +) { + window.statusBarColor = when { + backgroundColor != null -> backgroundColor + isLight -> ContextCompat.getColor(this, R.color.background_gray_01) + else -> Color.BLACK + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + window.insetsController?.setSystemBarsAppearance( + if (isLight) WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS else 0, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + ) + } else { + val lFlags = window.decorView.systemUiVisibility + window.decorView.systemUiVisibility = + if (isLight.not()) { + lFlags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() + } else { + lFlags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/LifecycleOwnerUtil.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/LifecycleOwnerUtil.kt new file mode 100644 index 000000000..4d5363478 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/LifecycleOwnerUtil.kt @@ -0,0 +1,15 @@ +package com.festago.festago.presentation.util + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch + +fun repeatOnStarted(lifecycleOwner: LifecycleOwner, action: suspend () -> Unit) { + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + action() + } + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/ParcelizeUtil.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/ParcelizeUtil.kt new file mode 100644 index 000000000..abdef748d --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/ParcelizeUtil.kt @@ -0,0 +1,34 @@ +package com.festago.festago.presentation.util + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.BundleCompat + +@Suppress("DEPRECATION") +inline fun Bundle.getParcelableCompat(key: String): T? { + return if (Build.VERSION.SDK_INT >= 33) { + getParcelable(key, T::class.java) + } else { + getParcelable(key) + } +} + +@Suppress("DEPRECATION") +inline fun Bundle.getParcelableArrayListCompat(key: String): ArrayList? { + return if (Build.VERSION.SDK_INT >= 33) { + getParcelableArrayList(key, T::class.java) + } else { + getParcelableArrayList(key) + } +} + +inline fun Intent.getParcelableExtraCompat(key: String): T? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val bundle = extras ?: return null + return BundleCompat.getParcelable(bundle, key, T::class.java) + } + @Suppress("DEPRECATION") + return getParcelableExtra(key) as? T +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/PermissionUtil.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/PermissionUtil.kt new file mode 100644 index 000000000..d8f257894 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/PermissionUtil.kt @@ -0,0 +1,42 @@ +package com.festago.festago.presentation.util + +import android.Manifest.permission.POST_NOTIFICATIONS +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.Build +import androidx.activity.result.ActivityResultLauncher +import androidx.core.content.ContextCompat + +fun Activity.requestNotificationPermission(resultLauncher: ActivityResultLauncher) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkSelfPermission( + this, + POST_NOTIFICATIONS, + onNotGranted = { + resultLauncher.launch(POST_NOTIFICATIONS) + } + ) + } +} + +fun checkNotificationPermission(context: Context, block: () -> Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkSelfPermission(context, POST_NOTIFICATIONS, onGranted = block) + } else { + block() + } +} + +fun checkSelfPermission( + context: Context, + permission: String, + onGranted: () -> Unit = {}, + onNotGranted: () -> Unit = {} +) { + if (ContextCompat.checkSelfPermission(context, permission) == PERMISSION_GRANTED) { + onGranted() + } else { + onNotGranted() + } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/SafeNavigate.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/SafeNavigate.kt new file mode 100644 index 000000000..ff37a6853 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/SafeNavigate.kt @@ -0,0 +1,8 @@ +package com.festago.festago.presentation.util + +import androidx.navigation.NavController +import androidx.navigation.NavDirections + +fun NavController.safeNavigate(direction: NavDirections) { + currentDestination?.getAction(direction.actionId)?.run { navigate(direction) } +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/SingleClickUtil.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/SingleClickUtil.kt new file mode 100644 index 000000000..ae4b96588 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/SingleClickUtil.kt @@ -0,0 +1,30 @@ +package com.festago.festago.presentation.util + +import android.os.SystemClock +import android.view.View +import androidx.databinding.BindingAdapter + +class OnSingleClickListener( + private var interval: Int = 600, + private var onSingleClick: (View) -> Unit, +) : View.OnClickListener { + + private var lastClickTime: Long = 0 + + override fun onClick(v: View) { + val elapsedRealtime = SystemClock.elapsedRealtime() + if ((elapsedRealtime - lastClickTime) < interval) { + return + } + lastClickTime = elapsedRealtime + onSingleClick(v) + } +} + +@BindingAdapter("onSingleClick") +fun View.setOnSingleClickListener(onSingleClick: (View) -> Unit) { + val oneClick = OnSingleClickListener { + onSingleClick(it) + } + setOnClickListener(oneClick) +} diff --git a/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/ViewUtil.kt b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/ViewUtil.kt new file mode 100644 index 000000000..40f2a0ba1 --- /dev/null +++ b/android/festago/presentation/src/main/java/com/festago/festago/presentation/util/ViewUtil.kt @@ -0,0 +1,9 @@ +package com.festago.festago.presentation.util + +import android.view.View +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat + +fun View.setOnApplyWindowInsetsCompatListener(listener: OnApplyWindowInsetsListener) { + ViewCompat.setOnApplyWindowInsetsListener(this, listener) +} diff --git a/android/festago/presentation/src/main/res/anim/fade_in.xml b/android/festago/presentation/src/main/res/anim/fade_in.xml new file mode 100644 index 000000000..eccea3eee --- /dev/null +++ b/android/festago/presentation/src/main/res/anim/fade_in.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/festago/presentation/src/main/res/anim/fade_out.xml b/android/festago/presentation/src/main/res/anim/fade_out.xml new file mode 100644 index 000000000..2c6541f39 --- /dev/null +++ b/android/festago/presentation/src/main/res/anim/fade_out.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/festago/presentation/src/main/res/anim/no_anim.xml b/android/festago/presentation/src/main/res/anim/no_anim.xml new file mode 100644 index 000000000..38da015aa --- /dev/null +++ b/android/festago/presentation/src/main/res/anim/no_anim.xml @@ -0,0 +1,6 @@ + + diff --git a/android/festago/presentation/src/main/res/anim/slide_in.xml b/android/festago/presentation/src/main/res/anim/slide_in.xml new file mode 100644 index 000000000..717f51ece --- /dev/null +++ b/android/festago/presentation/src/main/res/anim/slide_in.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/festago/presentation/src/main/res/anim/slide_out.xml b/android/festago/presentation/src/main/res/anim/slide_out.xml new file mode 100644 index 000000000..0983acd91 --- /dev/null +++ b/android/festago/presentation/src/main/res/anim/slide_out.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/festago/presentation/src/main/res/animator/nav_default_enter_anim.xml b/android/festago/presentation/src/main/res/animator/nav_default_enter_anim.xml new file mode 100644 index 000000000..7846f52e8 --- /dev/null +++ b/android/festago/presentation/src/main/res/animator/nav_default_enter_anim.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/festago/presentation/src/main/res/animator/nav_default_exit_anim.xml b/android/festago/presentation/src/main/res/animator/nav_default_exit_anim.xml new file mode 100644 index 000000000..7846f52e8 --- /dev/null +++ b/android/festago/presentation/src/main/res/animator/nav_default_exit_anim.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/festago/presentation/src/main/res/animator/nav_default_pop_enter_anim.xml b/android/festago/presentation/src/main/res/animator/nav_default_pop_enter_anim.xml new file mode 100644 index 000000000..7846f52e8 --- /dev/null +++ b/android/festago/presentation/src/main/res/animator/nav_default_pop_enter_anim.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/festago/presentation/src/main/res/animator/nav_default_pop_exit_anim.xml b/android/festago/presentation/src/main/res/animator/nav_default_pop_exit_anim.xml new file mode 100644 index 000000000..7846f52e8 --- /dev/null +++ b/android/festago/presentation/src/main/res/animator/nav_default_pop_exit_anim.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/festago/presentation/src/main/res/color/selector_btn_region_color.xml b/android/festago/presentation/src/main/res/color/selector_btn_region_color.xml new file mode 100644 index 000000000..c178c1e22 --- /dev/null +++ b/android/festago/presentation/src/main/res/color/selector_btn_region_color.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/color/tint_bookmark.xml b/android/festago/presentation/src/main/res/color/tint_bookmark.xml new file mode 100644 index 000000000..295be2749 --- /dev/null +++ b/android/festago/presentation/src/main/res/color/tint_bookmark.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_artist_detail_dday_in_progress.xml b/android/festago/presentation/src/main/res/drawable/bg_artist_detail_dday_in_progress.xml new file mode 100644 index 000000000..c6634b6ce --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_artist_detail_dday_in_progress.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_artist_stroke.xml b/android/festago/presentation/src/main/res/drawable/bg_artist_stroke.xml new file mode 100644 index 000000000..2daa4ea8d --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_artist_stroke.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_btn_refresh.xml b/android/festago/presentation/src/main/res/drawable/bg_btn_refresh.xml new file mode 100644 index 000000000..b7aac2168 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_btn_refresh.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_btn_region_check.xml b/android/festago/presentation/src/main/res/drawable/bg_btn_region_check.xml new file mode 100644 index 000000000..b513a8b9b --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_btn_region_check.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_btn_region_normal.xml b/android/festago/presentation/src/main/res/drawable/bg_btn_region_normal.xml new file mode 100644 index 000000000..34b6f2806 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_btn_region_normal.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_festago_default.xml b/android/festago/presentation/src/main/res/drawable/bg_festago_default.xml new file mode 100644 index 000000000..d6015060e --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_festago_default.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_festival_detail_dday_end.xml b/android/festago/presentation/src/main/res/drawable/bg_festival_detail_dday_end.xml new file mode 100644 index 000000000..b022949e6 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_festival_detail_dday_end.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_festival_list_dday_in_progress.xml b/android/festago/presentation/src/main/res/drawable/bg_festival_list_dday_in_progress.xml new file mode 100644 index 000000000..e4a627b7f --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_festival_list_dday_in_progress.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/android/festago/presentation/src/main/res/drawable/bg_festival_list_festival.xml b/android/festago/presentation/src/main/res/drawable/bg_festival_list_festival.xml new file mode 100644 index 000000000..90c660f54 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_festival_list_festival.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_kakao_sign_in.xml b/android/festago/presentation/src/main/res/drawable/bg_kakao_sign_in.xml new file mode 100644 index 000000000..b4e9fe9bc --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_kakao_sign_in.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_region_bottom_sheet.xml b/android/festago/presentation/src/main/res/drawable/bg_region_bottom_sheet.xml new file mode 100644 index 000000000..fb5f2c657 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/bg_region_bottom_sheet.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/bg_sign_in.png b/android/festago/presentation/src/main/res/drawable/bg_sign_in.png new file mode 100644 index 000000000..921fdc3ba Binary files /dev/null and b/android/festago/presentation/src/main/res/drawable/bg_sign_in.png differ diff --git a/android/festago/presentation/src/main/res/drawable/ic_back.xml b/android/festago/presentation/src/main/res/drawable/ic_back.xml new file mode 100644 index 000000000..b2d774246 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_back.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_back_gray_07.xml b/android/festago/presentation/src/main/res/drawable/ic_back_gray_07.xml new file mode 100644 index 000000000..58dfdf648 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_back_gray_07.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_bell.xml b/android/festago/presentation/src/main/res/drawable/ic_bell.xml new file mode 100644 index 000000000..f2043b1ce --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_bell.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_bookmark.xml b/android/festago/presentation/src/main/res/drawable/ic_bookmark.xml new file mode 100644 index 000000000..8d385e8cc --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_bookmark.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_bookmark_check.xml b/android/festago/presentation/src/main/res/drawable/ic_bookmark_check.xml new file mode 100644 index 000000000..78c257ce9 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_bookmark_check.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_bookmark_normal.xml b/android/festago/presentation/src/main/res/drawable/ic_bookmark_normal.xml new file mode 100644 index 000000000..a854be049 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_bookmark_normal.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_bookmark.xml b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_bookmark.xml new file mode 100644 index 000000000..c6b475df5 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_bookmark.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_home.xml b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_home.xml new file mode 100644 index 000000000..45e185a1e --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_home.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_my_page.xml b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_my_page.xml new file mode 100644 index 000000000..7490aa094 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_my_page.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_search.xml b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_search.xml new file mode 100644 index 000000000..221061ad0 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_search.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_ticket.xml b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_ticket.xml new file mode 100644 index 000000000..dcdd41c30 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_bottom_nav_ticket.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_circle_close.xml b/android/festago/presentation/src/main/res/drawable/ic_circle_close.xml new file mode 100644 index 000000000..b12812144 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_circle_close.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_close.xml b/android/festago/presentation/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000..03c1b023f --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_dot_default.xml b/android/festago/presentation/src/main/res/drawable/ic_dot_default.xml new file mode 100644 index 000000000..c980ed1d4 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_dot_default.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_dot_selected.xml b/android/festago/presentation/src/main/res/drawable/ic_dot_selected.xml new file mode 100644 index 000000000..28c930e22 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_dot_selected.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_facebook.xml b/android/festago/presentation/src/main/res/drawable/ic_facebook.xml new file mode 100644 index 000000000..5a8f232ef --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_facebook.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_festago_text_logo.xml b/android/festago/presentation/src/main/res/drawable/ic_festago_text_logo.xml new file mode 100644 index 000000000..b7dc1feb2 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_festago_text_logo.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_instagram.xml b/android/festago/presentation/src/main/res/drawable/ic_instagram.xml new file mode 100644 index 000000000..d7948a451 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_instagram.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_kakao_logo.xml b/android/festago/presentation/src/main/res/drawable/ic_kakao_logo.xml new file mode 100644 index 000000000..7d61060d2 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_kakao_logo.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_launcher_background.xml b/android/festago/presentation/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..90aba7634 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_loading.xml b/android/festago/presentation/src/main/res/drawable/ic_loading.xml new file mode 100644 index 000000000..ec9820835 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_loading.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_next.xml b/android/festago/presentation/src/main/res/drawable/ic_next.xml new file mode 100644 index 000000000..d9d292377 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_next.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_pin_location.xml b/android/festago/presentation/src/main/res/drawable/ic_pin_location.xml new file mode 100644 index 000000000..a27f2fb2b --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_pin_location.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_pin_normal.xml b/android/festago/presentation/src/main/res/drawable/ic_pin_normal.xml new file mode 100644 index 000000000..6c804a1cb --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_pin_normal.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_pin_select.xml b/android/festago/presentation/src/main/res/drawable/ic_pin_select.xml new file mode 100644 index 000000000..ec5d23a6f --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_pin_select.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_refresh.xml b/android/festago/presentation/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..9304d5b0b --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_search.xml b/android/festago/presentation/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..d2330ea79 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_search.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_user_profile_default.xml b/android/festago/presentation/src/main/res/drawable/ic_user_profile_default.xml new file mode 100644 index 000000000..3e746a890 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_user_profile_default.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_x.xml b/android/festago/presentation/src/main/res/drawable/ic_x.xml new file mode 100644 index 000000000..f1b05b5a4 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_x.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_x_close.xml b/android/festago/presentation/src/main/res/drawable/ic_x_close.xml new file mode 100644 index 000000000..0d1129d89 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_x_close.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/ic_youtube.xml b/android/festago/presentation/src/main/res/drawable/ic_youtube.xml new file mode 100644 index 000000000..2c302527b --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/ic_youtube.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/festago/presentation/src/main/res/drawable/img_default_school.png b/android/festago/presentation/src/main/res/drawable/img_default_school.png new file mode 100644 index 000000000..d06870463 Binary files /dev/null and b/android/festago/presentation/src/main/res/drawable/img_default_school.png differ diff --git a/android/festago/presentation/src/main/res/drawable/img_festival_list_festago_logo.xml b/android/festago/presentation/src/main/res/drawable/img_festival_list_festago_logo.xml new file mode 100644 index 000000000..5fbbbe0d7 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/img_festival_list_festago_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/festago/presentation/src/main/res/drawable/img_school_background.png b/android/festago/presentation/src/main/res/drawable/img_school_background.png new file mode 100644 index 000000000..b49156baf Binary files /dev/null and b/android/festago/presentation/src/main/res/drawable/img_school_background.png differ diff --git a/android/festago/presentation/src/main/res/drawable/selector_btn_region.xml b/android/festago/presentation/src/main/res/drawable/selector_btn_region.xml new file mode 100644 index 000000000..62a91b582 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/selector_btn_region.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/selector_menu_color.xml b/android/festago/presentation/src/main/res/drawable/selector_menu_color.xml new file mode 100644 index 000000000..d8de24298 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/selector_menu_color.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/festago/presentation/src/main/res/drawable/selector_tab_page.xml b/android/festago/presentation/src/main/res/drawable/selector_tab_page.xml new file mode 100644 index 000000000..dd02f3394 --- /dev/null +++ b/android/festago/presentation/src/main/res/drawable/selector_tab_page.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/font/pretendard_bold.otf b/android/festago/presentation/src/main/res/font/pretendard_bold.otf new file mode 100644 index 000000000..8e5e30a28 Binary files /dev/null and b/android/festago/presentation/src/main/res/font/pretendard_bold.otf differ diff --git a/android/festago/presentation/src/main/res/font/pretendard_medium.otf b/android/festago/presentation/src/main/res/font/pretendard_medium.otf new file mode 100644 index 000000000..057506983 Binary files /dev/null and b/android/festago/presentation/src/main/res/font/pretendard_medium.otf differ diff --git a/android/festago/presentation/src/main/res/layout/activity_home.xml b/android/festago/presentation/src/main/res/layout/activity_home.xml new file mode 100644 index 000000000..620ccc73a --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/activity_home.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/activity_notification_list.xml b/android/festago/presentation/src/main/res/layout/activity_notification_list.xml new file mode 100644 index 000000000..d132f9d45 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/activity_notification_list.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/activity_sign_in.xml b/android/festago/presentation/src/main/res/layout/activity_sign_in.xml new file mode 100644 index 000000000..a94efe74c --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/activity_sign_in.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/activity_splash.xml b/android/festago/presentation/src/main/res/layout/activity_splash.xml new file mode 100644 index 000000000..ce74f886d --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/activity_splash.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/btn_festago.xml b/android/festago/presentation/src/main/res/layout/btn_festago.xml new file mode 100644 index 000000000..72950f945 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/btn_festago.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_artist_bookmark.xml b/android/festago/presentation/src/main/res/layout/fragment_artist_bookmark.xml new file mode 100644 index 000000000..8e18e2bad --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_artist_bookmark.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_artist_detail.xml b/android/festago/presentation/src/main/res/layout/fragment_artist_detail.xml new file mode 100644 index 000000000..2477b67cc --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_artist_detail.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_bookmark_list.xml b/android/festago/presentation/src/main/res/layout/fragment_bookmark_list.xml new file mode 100644 index 000000000..e33044f1f --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_bookmark_list.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_festival_bookmark.xml b/android/festago/presentation/src/main/res/layout/fragment_festival_bookmark.xml new file mode 100644 index 000000000..1b8acc005 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_festival_bookmark.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_festival_detail.xml b/android/festago/presentation/src/main/res/layout/fragment_festival_detail.xml new file mode 100644 index 000000000..54ad4a191 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_festival_detail.xml @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_festival_list.xml b/android/festago/presentation/src/main/res/layout/fragment_festival_list.xml new file mode 100644 index 000000000..6e235680d --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_festival_list.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_my_page.xml b/android/festago/presentation/src/main/res/layout/fragment_my_page.xml new file mode 100644 index 000000000..6f6e818aa --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_my_page.xml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_region_bottom_sheet.xml b/android/festago/presentation/src/main/res/layout/fragment_region_bottom_sheet.xml new file mode 100644 index 000000000..45b771a36 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_region_bottom_sheet.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_school_bookmark.xml b/android/festago/presentation/src/main/res/layout/fragment_school_bookmark.xml new file mode 100644 index 000000000..7144e2ec9 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_school_bookmark.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_school_detail.xml b/android/festago/presentation/src/main/res/layout/fragment_school_detail.xml new file mode 100644 index 000000000..87b68aa7a --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_school_detail.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_search.xml b/android/festago/presentation/src/main/res/layout/fragment_search.xml new file mode 100644 index 000000000..3d8c60623 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_search.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/fragment_ticket_list.xml b/android/festago/presentation/src/main/res/layout/fragment_ticket_list.xml new file mode 100644 index 000000000..aefa93239 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/fragment_ticket_list.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_artist_bookmark.xml b/android/festago/presentation/src/main/res/layout/item_artist_bookmark.xml new file mode 100644 index 000000000..bf2ee2269 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_artist_bookmark.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_artist_detail_artist.xml b/android/festago/presentation/src/main/res/layout/item_artist_detail_artist.xml new file mode 100644 index 000000000..78ba85537 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_artist_detail_artist.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_artist_detail_festival.xml b/android/festago/presentation/src/main/res/layout/item_artist_detail_festival.xml new file mode 100644 index 000000000..9de0574d5 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_artist_detail_festival.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_artist_detail_more_item.xml b/android/festago/presentation/src/main/res/layout/item_artist_detail_more_item.xml new file mode 100644 index 000000000..41942b174 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_artist_detail_more_item.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_artist_search_screen.xml b/android/festago/presentation/src/main/res/layout/item_artist_search_screen.xml new file mode 100644 index 000000000..36e3037be --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_artist_search_screen.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_bookmark.xml b/android/festago/presentation/src/main/res/layout/item_festival_bookmark.xml new file mode 100644 index 000000000..340813ab7 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_bookmark.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_bookmark_artist.xml b/android/festago/presentation/src/main/res/layout/item_festival_bookmark_artist.xml new file mode 100644 index 000000000..a4219675d --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_bookmark_artist.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_detail_stage.xml b/android/festago/presentation/src/main/res/layout/item_festival_detail_stage.xml new file mode 100644 index 000000000..18b80ad51 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_detail_stage.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_detail_stage_artist.xml b/android/festago/presentation/src/main/res/layout/item_festival_detail_stage_artist.xml new file mode 100644 index 000000000..27adc88db --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_detail_stage_artist.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_list_artist.xml b/android/festago/presentation/src/main/res/layout/item_festival_list_artist.xml new file mode 100644 index 000000000..95b8083fd --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_list_artist.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_list_empty_item.xml b/android/festago/presentation/src/main/res/layout/item_festival_list_empty_item.xml new file mode 100644 index 000000000..56cb17daa --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_list_empty_item.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_list_festival.xml b/android/festago/presentation/src/main/res/layout/item_festival_list_festival.xml new file mode 100644 index 000000000..342dff42f --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_list_festival.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_list_more_item.xml b/android/festago/presentation/src/main/res/layout/item_festival_list_more_item.xml new file mode 100644 index 000000000..41942b174 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_list_more_item.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_list_popular.xml b/android/festago/presentation/src/main/res/layout/item_festival_list_popular.xml new file mode 100644 index 000000000..f407a234a --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_list_popular.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_list_tab.xml b/android/festago/presentation/src/main/res/layout/item_festival_list_tab.xml new file mode 100644 index 000000000..aa64c7b69 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_list_tab.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_festival_search_screen.xml b/android/festago/presentation/src/main/res/layout/item_festival_search_screen.xml new file mode 100644 index 000000000..30893979e --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_festival_search_screen.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_media.xml b/android/festago/presentation/src/main/res/layout/item_media.xml new file mode 100644 index 000000000..3fe111bf6 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_media.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_popular_festival_background.xml b/android/festago/presentation/src/main/res/layout/item_popular_festival_background.xml new file mode 100644 index 000000000..857e4aa0d --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_popular_festival_background.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_popular_festival_foreground.xml b/android/festago/presentation/src/main/res/layout/item_popular_festival_foreground.xml new file mode 100644 index 000000000..c9d8dc488 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_popular_festival_foreground.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_recent_search.xml b/android/festago/presentation/src/main/res/layout/item_recent_search.xml new file mode 100644 index 000000000..dff693669 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_recent_search.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_region.xml b/android/festago/presentation/src/main/res/layout/item_region.xml new file mode 100644 index 000000000..30556bc16 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_region.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_school_bookmark.xml b/android/festago/presentation/src/main/res/layout/item_school_bookmark.xml new file mode 100644 index 000000000..7071d5244 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_school_bookmark.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_school_detail_artist.xml b/android/festago/presentation/src/main/res/layout/item_school_detail_artist.xml new file mode 100644 index 000000000..2a84f12b6 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_school_detail_artist.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_school_detail_festival.xml b/android/festago/presentation/src/main/res/layout/item_school_detail_festival.xml new file mode 100644 index 000000000..14e2619fd --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_school_detail_festival.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_school_detail_more_item.xml b/android/festago/presentation/src/main/res/layout/item_school_detail_more_item.xml new file mode 100644 index 000000000..41942b174 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_school_detail_more_item.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_school_search_screen.xml b/android/festago/presentation/src/main/res/layout/item_school_search_screen.xml new file mode 100644 index 000000000..bf5c0dc52 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_school_search_screen.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_search_artist.xml b/android/festago/presentation/src/main/res/layout/item_search_artist.xml new file mode 100644 index 000000000..707cec753 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_search_artist.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_search_festival.xml b/android/festago/presentation/src/main/res/layout/item_search_festival.xml new file mode 100644 index 000000000..e1b4c595c --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_search_festival.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_search_festival_artist.xml b/android/festago/presentation/src/main/res/layout/item_search_festival_artist.xml new file mode 100644 index 000000000..ed9ceb641 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_search_festival_artist.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/item_search_school.xml b/android/festago/presentation/src/main/res/layout/item_search_school.xml new file mode 100644 index 000000000..fa16bda17 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/item_search_school.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/layout_network_error.xml b/android/festago/presentation/src/main/res/layout/layout_network_error.xml new file mode 100644 index 000000000..e1655a9b0 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/layout_network_error.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/layout_require_login.xml b/android/festago/presentation/src/main/res/layout/layout_require_login.xml new file mode 100644 index 000000000..343d970cc --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/layout_require_login.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/layout/view_social_media.xml b/android/festago/presentation/src/main/res/layout/view_social_media.xml new file mode 100644 index 000000000..fb2db4334 --- /dev/null +++ b/android/festago/presentation/src/main/res/layout/view_social_media.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/menu/menu_bottom_navigation.xml b/android/festago/presentation/src/main/res/menu/menu_bottom_navigation.xml new file mode 100644 index 000000000..0b73b26f8 --- /dev/null +++ b/android/festago/presentation/src/main/res/menu/menu_bottom_navigation.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/festago/presentation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..be316184e --- /dev/null +++ b/android/festago/presentation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/festago/presentation/src/main/res/mipmap-hdpi/ic_launcher.png b/android/festago/presentation/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..90e99352d Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-hdpi/ic_launcher_background.png b/android/festago/presentation/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 000000000..b0f574f56 Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/festago/presentation/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..6f43f3e00 Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/festago/presentation/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..397f691c6 Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/android/festago/presentation/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 000000000..e6c7bf953 Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/festago/presentation/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..b4a04182a Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/festago/presentation/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..e230591d7 Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/android/festago/presentation/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 000000000..355d5641c Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/festago/presentation/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..d566adc16 Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/festago/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..60a750b3b Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/festago/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 000000000..04ce89f14 Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/android/festago/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/festago/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..277507a2a Binary files /dev/null and b/android/festago/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/festago/presentation/src/main/res/navigation/book_mark.xml b/android/festago/presentation/src/main/res/navigation/book_mark.xml new file mode 100644 index 000000000..b27e77d5e --- /dev/null +++ b/android/festago/presentation/src/main/res/navigation/book_mark.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/navigation/festival_list.xml b/android/festago/presentation/src/main/res/navigation/festival_list.xml new file mode 100644 index 000000000..1eb829b11 --- /dev/null +++ b/android/festago/presentation/src/main/res/navigation/festival_list.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/navigation/home_graph.xml b/android/festago/presentation/src/main/res/navigation/home_graph.xml new file mode 100644 index 000000000..8192e2186 --- /dev/null +++ b/android/festago/presentation/src/main/res/navigation/home_graph.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/navigation/my_page.xml b/android/festago/presentation/src/main/res/navigation/my_page.xml new file mode 100644 index 000000000..242f48a21 --- /dev/null +++ b/android/festago/presentation/src/main/res/navigation/my_page.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/navigation/search.xml b/android/festago/presentation/src/main/res/navigation/search.xml new file mode 100644 index 000000000..66488b33b --- /dev/null +++ b/android/festago/presentation/src/main/res/navigation/search.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/navigation/ticket_list.xml b/android/festago/presentation/src/main/res/navigation/ticket_list.xml new file mode 100644 index 000000000..bf1d81038 --- /dev/null +++ b/android/festago/presentation/src/main/res/navigation/ticket_list.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/festago/presentation/src/main/res/values-night/themes.xml b/android/festago/presentation/src/main/res/values-night/themes.xml new file mode 100644 index 000000000..c913337bf --- /dev/null +++ b/android/festago/presentation/src/main/res/values-night/themes.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/values/attrs.xml b/android/festago/presentation/src/main/res/values/attrs.xml new file mode 100644 index 000000000..23b2df7d8 --- /dev/null +++ b/android/festago/presentation/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/values/colors.xml b/android/festago/presentation/src/main/res/values/colors.xml new file mode 100644 index 000000000..60638cbcd --- /dev/null +++ b/android/festago/presentation/src/main/res/values/colors.xml @@ -0,0 +1,17 @@ + + + #ffffff + #fbfbfb + #efefef + #d1d1d1 + #b0b0b0 + #5d5d5d + #0c0c0c + + #e8f6ff + #043bff + + #fa1273 + + #00000000 + diff --git a/android/festago/presentation/src/main/res/values/integers.xml b/android/festago/presentation/src/main/res/values/integers.xml new file mode 100644 index 000000000..770416c1a --- /dev/null +++ b/android/festago/presentation/src/main/res/values/integers.xml @@ -0,0 +1,4 @@ + + + 300 + diff --git a/android/festago/presentation/src/main/res/values/strings.xml b/android/festago/presentation/src/main/res/values/strings.xml new file mode 100644 index 000000000..93d54c339 --- /dev/null +++ b/android/festago/presentation/src/main/res/values/strings.xml @@ -0,0 +1,129 @@ + + 페스타고 + + + 축제 목록 + 티켓 목록 + 검색 + 북마크 + 마이페이지 + 한 번 더 누르면 앱이 종료됩니다 + + + Hello blank fragment + 요즘 뜨는 축제 + %s - %s + yy.MM.dd + festival image + 진행 중 + 진행 예정 + 종료 + 진행 중 + D%1$s + + 진행 중인 축제가 없어요 + 진행 예정인 축제가 없어요 + 축제가 없어요 + + + D%1$s + 아직 축제가 없어요 + 북마크에 추가했어요 + 북마크에서 삭제했어요 + + + 알림 + 아직 도착한 알림이 없어요 + 새로운 소식이 도착하면 알려드릴게요 + + + 아직 라인업이 공개되지 않았어요 + 북마크에 추가했어요 + 북마크에서 삭제했어요 + + + 진행 중 + D%1$s + 종료 + + + 지역별 + + + 아직 축제가 없어요 + 북마크에 추가했어요 + 북마크에서 삭제했어요 + + + 학교명, 아티스트명, 축제명으로 입력하세요. + 최근 검색어 + 전체 삭제 + 최근 검색 내역이 없어요 + 축제 + 아티스트 + 학교 + 검색어를 입력해주세요 + 축제 중 + D%1$s + 예정 없음 + 오늘 공연 %d개 + 공연 예정 %d개 + 예정 없음 + 앗! 검색 결과가 없네요 + 찾으시는 정보가 없다면 저희에게 알려주세요 + 추가 요청하러 가기 + + + 업데이트 알림 + 새로운 페스타고를 사용하기 위해 업데이트 해주세요! + 업데이트 후 정상 사용가능합니다. + 인터넷 연결 및 최신 버전을 확인해주세요 + + + 확인 + 업데이트 + 취소 + + + 새로고침하기 + 잠시 후에 다시 시도해주세요 + 현재 접속이 원활하지 않아요 + + + 로그인하면 북마크 할 수 있어요 + 로그인 하러 가기 + + + 관심있는 아티스트를 북마크 할 수 있어요 + 관심있는 축제를 북마크 할 수 있어요 + 관심있는 학교를 북마크 할 수 있어요 + 축제 + 아티스트 + 학교 + + + 페스타고,\n대학축제를\n더욱 즐겁게! + 카카오로 로그인하기 + 로그인하지 않고 둘러보기 + 로그인 할 수 없습니다. 다시 로그인 하거나 앱을 재실행 해주세요. + 로그인에 성공했습니다 + + + + 로그인 하러 가기 + 문의하기 + FAQ + 개인정보 처리방침 + 앱버전 + 로그아웃 + 회원탈퇴 + 로그아웃 + 정말 로그아웃 하시겠습니까? + 회원 탈퇴 + 정말 탈퇴 하시겠습니까? 다시 되돌릴 수 없습니다. + 버전 : %s + + + 아직 라인업이\n공개되지 않았어요 + + diff --git a/android/festago/presentation/src/main/res/values/styles.xml b/android/festago/presentation/src/main/res/values/styles.xml new file mode 100644 index 000000000..4844cf13e --- /dev/null +++ b/android/festago/presentation/src/main/res/values/styles.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/festago/presentation/src/main/res/values/themes.xml b/android/festago/presentation/src/main/res/values/themes.xml new file mode 100644 index 000000000..c913337bf --- /dev/null +++ b/android/festago/presentation/src/main/res/values/themes.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/android/festago/presentation/src/main/res/xml/backup_rules.xml b/android/festago/presentation/src/main/res/xml/backup_rules.xml new file mode 100644 index 000000000..1f9baf65c --- /dev/null +++ b/android/festago/presentation/src/main/res/xml/backup_rules.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/festago/presentation/src/main/res/xml/data_extraction_rules.xml b/android/festago/presentation/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..7c4849a8f --- /dev/null +++ b/android/festago/presentation/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/festago/presentation/src/test/java/com/festago/festago/.gitkeep b/android/festago/presentation/src/test/java/com/festago/festago/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/festago/settings.gradle.kts b/android/festago/settings.gradle.kts index e90fbfa66..8777080c5 100644 --- a/android/festago/settings.gradle.kts +++ b/android/festago/settings.gradle.kts @@ -14,5 +14,20 @@ dependencyResolutionManagement { } } rootProject.name = "festago" +// app include(":app") + +// common +include(":common") + +// domain +include(":domain-legacy") include(":domain") + +// presentation +include(":presentation-legacy") + +include(":presentation") +// data +include(":data-legacy") +include(":data") diff --git a/backend/.gitignore b/backend/.gitignore index b506e3002..2ac591b77 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -37,3 +37,9 @@ out/ ### VS Code ### .vscode/ + +### QUERYDSL ### +/src/main/generated/ + +### APPLICATION PROPERTIES ### +src/main/resources/application-local.yml diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..99f3e9e94 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17-jdk +ARG JAR_FILE_PATH=./build/libs/*.jar +COPY ${JAR_FILE_PATH} app.jar +ENTRYPOINT ["java", "-jar", "-Duser.timezone=Asia/Seoul", "app.jar"] diff --git a/backend/build.gradle b/backend/build.gradle deleted file mode 100644 index b8d91cef4..000000000 --- a/backend/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -plugins { - id 'java' - id 'org.springframework.boot' version '3.1.4' - id 'io.spring.dependency-management' version '1.1.0' -} - -group = 'com' -version = '0.0.1-SNAPSHOT' - -java { - sourceCompatibility = '17' -} - -repositories { - mavenCentral() -} - -springBoot { - buildInfo() -} - -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.springframework.boot:spring-boot-starter-mail' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' - - runtimeOnly 'com.h2database:h2' - runtimeOnly 'com.mysql:mysql-connector-j' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'io.rest-assured:rest-assured:5.3.0' - - // JWT - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - - // Logback Slack Alarm - implementation "com.github.maricn:logback-slack-appender:1.4.0" - - // Cucumber - testImplementation 'io.cucumber:cucumber-java:7.13.0' - testImplementation 'io.cucumber:cucumber-spring:7.13.0' - testImplementation 'io.cucumber:cucumber-junit-platform-engine:7.13.0' - testImplementation 'org.junit.platform:junit-platform-suite:1.8.2' - - // Flyway - implementation 'org.flywaydb:flyway-core' - implementation 'org.flywaydb:flyway-mysql' - - // Firebase - implementation 'com.google.firebase:firebase-admin:8.1.0' - - // Lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testCompileOnly 'org.projectlombok:lombok' - testAnnotationProcessor 'org.projectlombok:lombok' -} - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 000000000..4378375fc --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,101 @@ +plugins { + id("java") + id("org.springframework.boot") version "3.2.5" + id("io.spring.dependency-management") version "1.1.0" +} + +group = "com" +version = "0.0.1-SNAPSHOT" + +java { + sourceCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +springBoot { + buildInfo() +} + +configurations.all { + exclude(group = "commons-logging", module = "commons-logging") +} + +val swaggerVersion = "2.0.2" +val restAssuredVersion = "5.3.0" +val jjwtVersion = "0.12.5" +val logbackSlackAppenderVersion = "1.4.0" +val cucumberVersion = "7.13.0" +val firebaseVersion = "8.1.0" +val awsS3Version = "2.25.40" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${swaggerVersion}") + + // Spring Security + implementation("org.springframework.security:spring-security-crypto") + + runtimeOnly("com.h2database:h2") + runtimeOnly("com.mysql:mysql-connector-j") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.rest-assured:rest-assured:${restAssuredVersion}") + + // JWT + implementation("io.jsonwebtoken:jjwt-api:${jjwtVersion}") + runtimeOnly("io.jsonwebtoken:jjwt-impl:${jjwtVersion}") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:${jjwtVersion}") + + // Logback Slack Alarm + implementation("com.github.maricn:logback-slack-appender:${logbackSlackAppenderVersion}") + + // Cucumber + testImplementation("io.cucumber:cucumber-java:${cucumberVersion}") + testImplementation("io.cucumber:cucumber-spring:${cucumberVersion}") + testImplementation("io.cucumber:cucumber-junit-platform-engine:${cucumberVersion}") + testImplementation("org.junit.platform:junit-platform-suite") + + // Querydsl + implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") + annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + // Flyway + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-mysql") + + // Firebase + implementation("com.google.firebase:firebase-admin:${firebaseVersion}") + + // Lombok + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + testCompileOnly("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") + + // Micrometer + runtimeOnly("io.micrometer:micrometer-registry-prometheus") + + // AWS S3 + implementation("software.amazon.awssdk:s3:${awsS3Version}") +} + +tasks.test { + useJUnitPlatform() +} + +// Querydsl 폴더 지정 +val querydslDir = file("build/generated/querydsl") + +// querydsl QClass 파일 생성 위치를 지정 +tasks.compileJava { + options.generatedSourceOutputDirectory.set(querydslDir) +} diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties index 37aef8d3f..3499ded5c 100644 --- a/backend/gradle/wrapper/gradle-wrapper.properties +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/backend/settings.gradle b/backend/settings.gradle deleted file mode 100644 index e5e1e09bf..000000000 --- a/backend/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'festa-go' diff --git a/backend/settings.gradle.kts b/backend/settings.gradle.kts new file mode 100644 index 000000000..8b7b10e72 --- /dev/null +++ b/backend/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "festa-go" diff --git a/backend/src/main/java/com/festago/admin/application/AdminActuatorProxyService.java b/backend/src/main/java/com/festago/admin/application/AdminActuatorProxyService.java new file mode 100644 index 000000000..912a351f3 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/application/AdminActuatorProxyService.java @@ -0,0 +1,17 @@ +package com.festago.admin.application; + +import com.festago.admin.infrastructure.ActuatorProxyClient; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminActuatorProxyService { + + private final ActuatorProxyClient actuatorProxyClient; + + public ResponseEntity request(String path) { + return actuatorProxyClient.request(path); + } +} diff --git a/backend/src/main/java/com/festago/admin/application/AdminArtistV1QueryService.java b/backend/src/main/java/com/festago/admin/application/AdminArtistV1QueryService.java new file mode 100644 index 000000000..e43f1c936 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/application/AdminArtistV1QueryService.java @@ -0,0 +1,28 @@ +package com.festago.admin.application; + +import com.festago.admin.dto.artist.AdminArtistV1Response; +import com.festago.admin.repository.AdminArtistV1QueryDslRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.querydsl.SearchCondition; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminArtistV1QueryService { + + private final AdminArtistV1QueryDslRepository adminArtistV1QueryDslRepository; + + public AdminArtistV1Response findById(Long artistId) { + return adminArtistV1QueryDslRepository.findById(artistId) + .orElseThrow(() -> new NotFoundException(ErrorCode.ARTIST_NOT_FOUND)); + } + + public Page findAll(SearchCondition searchCondition) { + return adminArtistV1QueryDslRepository.findAll(searchCondition); + } +} diff --git a/backend/src/main/java/com/festago/admin/application/AdminFestivalV1QueryService.java b/backend/src/main/java/com/festago/admin/application/AdminFestivalV1QueryService.java new file mode 100644 index 000000000..43bbffe1b --- /dev/null +++ b/backend/src/main/java/com/festago/admin/application/AdminFestivalV1QueryService.java @@ -0,0 +1,31 @@ +package com.festago.admin.application; + +import com.festago.admin.dto.festival.AdminFestivalDetailV1Response; +import com.festago.admin.dto.festival.AdminFestivalV1Response; +import com.festago.admin.repository.AdminFestivalDetailV1QueryDslRepository; +import com.festago.admin.repository.AdminFestivalV1QueryDslRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.querydsl.SearchCondition; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminFestivalV1QueryService { + + private final AdminFestivalV1QueryDslRepository adminFestivalV1QueryDslRepository; + private final AdminFestivalDetailV1QueryDslRepository adminFestivalDetailV1QueryDslRepository; + + public Page findAll(SearchCondition searchCondition) { + return adminFestivalV1QueryDslRepository.findAll(searchCondition); + } + + public AdminFestivalDetailV1Response findDetail(Long festivalId) { + return adminFestivalDetailV1QueryDslRepository.findDetail(festivalId) + .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/festago/admin/application/AdminQueryInfoRenewalService.java b/backend/src/main/java/com/festago/admin/application/AdminQueryInfoRenewalService.java new file mode 100644 index 000000000..3d481ab51 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/application/AdminQueryInfoRenewalService.java @@ -0,0 +1,38 @@ +package com.festago.admin.application; + +import com.festago.admin.repository.AdminFestivalIdResolverQueryDslRepository; +import com.festago.admin.repository.AdminStageIdResolverQueryDslRepository; +import com.festago.festival.application.FestivalQueryInfoArtistRenewService; +import com.festago.stage.application.StageQueryInfoService; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class AdminQueryInfoRenewalService { + + private final FestivalQueryInfoArtistRenewService festivalQueryInfoArtistRenewService; + private final StageQueryInfoService stageQueryInfoService; + private final AdminStageIdResolverQueryDslRepository adminStageIdResolverQueryDslRepository; + private final AdminFestivalIdResolverQueryDslRepository adminFestivalIdResolverQueryDslRepository; + + public void renewalByFestivalId(Long festivalId) { + festivalQueryInfoArtistRenewService.renewArtistInfo(festivalId); + adminStageIdResolverQueryDslRepository.findStageIdsByFestivalId(festivalId) + .forEach(stageQueryInfoService::renewalStageQueryInfo); + } + + public void renewalByFestivalStartDatePeriod(LocalDate to, LocalDate end) { + List festivalIds = adminFestivalIdResolverQueryDslRepository.findFestivalIdsByStartDatePeriod(to, end); + log.info("{}개의 축제에 대해 QueryInfo를 새로 갱신합니다.", festivalIds.size()); + festivalIds.forEach(festivalQueryInfoArtistRenewService::renewArtistInfo); + adminStageIdResolverQueryDslRepository.findStageIdsByFestivalIdIn(festivalIds) + .forEach(stageQueryInfoService::renewalStageQueryInfo); + } +} diff --git a/backend/src/main/java/com/festago/admin/application/AdminSchoolV1QueryService.java b/backend/src/main/java/com/festago/admin/application/AdminSchoolV1QueryService.java new file mode 100644 index 000000000..4e39a69d3 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/application/AdminSchoolV1QueryService.java @@ -0,0 +1,28 @@ +package com.festago.admin.application; + +import com.festago.admin.dto.school.AdminSchoolV1Response; +import com.festago.admin.repository.AdminSchoolV1QueryDslRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.querydsl.SearchCondition; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminSchoolV1QueryService { + + private final AdminSchoolV1QueryDslRepository schoolQueryDslRepository; + + public Page findAll(SearchCondition searchCondition) { + return schoolQueryDslRepository.findAll(searchCondition); + } + + public AdminSchoolV1Response findById(Long schoolId) { + return schoolQueryDslRepository.findById(schoolId) + .orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/festago/admin/application/AdminService.java b/backend/src/main/java/com/festago/admin/application/AdminService.java deleted file mode 100644 index 6caf46f1a..000000000 --- a/backend/src/main/java/com/festago/admin/application/AdminService.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.festago.admin.application; - -import com.festago.admin.dto.AdminFestivalResponse; -import com.festago.admin.dto.AdminResponse; -import com.festago.admin.dto.AdminSchoolResponse; -import com.festago.admin.dto.AdminStageResponse; -import com.festago.admin.dto.AdminTicketResponse; -import com.festago.festival.domain.Festival; -import com.festago.festival.repository.FestivalRepository; -import com.festago.school.domain.School; -import com.festago.school.repository.SchoolRepository; -import com.festago.stage.domain.Stage; -import com.festago.stage.repository.StageRepository; -import com.festago.ticket.domain.Ticket; -import com.festago.ticket.domain.TicketAmount; -import com.festago.ticket.domain.TicketEntryTime; -import com.festago.ticket.repository.TicketRepository; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Transactional -@Service -@RequiredArgsConstructor -public class AdminService { - - private final FestivalRepository festivalRepository; - private final StageRepository stageRepository; - private final TicketRepository ticketRepository; - private final SchoolRepository schoolRepository; - - @Transactional(readOnly = true) - public AdminResponse getAdminResponse() { - List allSchool = schoolRepository.findAll(); - List allTicket = ticketRepository.findAll(); - List allStage = stageRepository.findAll(); - List allFestival = festivalRepository.findAll(); - return new AdminResponse( - schoolResponses(allSchool), - ticketResponses(allTicket), - stageResponses(allStage), - festivalResponses(allFestival)); - } - - private List schoolResponses(List schools) { - return schools.stream() - .map(AdminSchoolResponse::from) - .toList(); - } - - private List ticketResponses(List tickets) { - return tickets.stream() - .map(this::ticketResponse) - .toList(); - } - - private AdminTicketResponse ticketResponse(Ticket ticket) { - TicketAmount ticketAmount = ticket.getTicketAmount(); - Map amountByEntryTime = ticket.getTicketEntryTimes().stream() - .collect(Collectors.toMap( - TicketEntryTime::getEntryTime, - TicketEntryTime::getAmount - )); - return new AdminTicketResponse( - ticket.getId(), - ticket.getStage().getId(), - ticket.getTicketType(), - ticketAmount.getTotalAmount(), - ticketAmount.getReservedAmount(), - amountByEntryTime - ); - } - - private List stageResponses(List stages) { - return stages.stream() - .map(this::stageResponse) - .toList(); - } - - private AdminStageResponse stageResponse(Stage stage) { - List ticketIds = stage.getTickets().stream() - .map(Ticket::getId) - .toList(); - return new AdminStageResponse( - stage.getId(), - stage.getFestival().getId(), - stage.getStartTime(), - stage.getLineUp(), - ticketIds - ); - } - - private List festivalResponses(List festivals) { - return festivals.stream() - .map(festival -> new AdminFestivalResponse( - festival.getId(), - festival.getSchool().getId(), - festival.getName(), - festival.getStartDate(), - festival.getEndDate(), - festival.getThumbnail())) - .toList(); - } -} diff --git a/backend/src/main/java/com/festago/admin/application/AdminSocialMediaV1QueryService.java b/backend/src/main/java/com/festago/admin/application/AdminSocialMediaV1QueryService.java new file mode 100644 index 000000000..947e76883 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/application/AdminSocialMediaV1QueryService.java @@ -0,0 +1,28 @@ +package com.festago.admin.application; + +import com.festago.admin.dto.socialmedia.AdminSocialMediaV1Response; +import com.festago.admin.repository.AdminSocialMediaV1QueryDslRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.socialmedia.domain.OwnerType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminSocialMediaV1QueryService { + + private final AdminSocialMediaV1QueryDslRepository adminSocialMediaV1QueryDslRepository; + + public AdminSocialMediaV1Response findById(Long socialMediaId) { + return adminSocialMediaV1QueryDslRepository.findById(socialMediaId) + .orElseThrow(() -> new NotFoundException(ErrorCode.SOCIAL_MEDIA_NOT_FOUND)); + } + + public List findByOwnerIdAndOwnerType(Long ownerId, OwnerType ownerType) { + return adminSocialMediaV1QueryDslRepository.findByOwnerIdAndOwnerType(ownerId, ownerType); + } +} diff --git a/backend/src/main/java/com/festago/admin/application/AdminStageV1QueryService.java b/backend/src/main/java/com/festago/admin/application/AdminStageV1QueryService.java new file mode 100644 index 000000000..298535195 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/application/AdminStageV1QueryService.java @@ -0,0 +1,27 @@ +package com.festago.admin.application; + +import com.festago.admin.dto.stage.AdminStageV1Response; +import com.festago.admin.repository.AdminStageV1QueryDslRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminStageV1QueryService { + + private final AdminStageV1QueryDslRepository adminStageV1QueryDslRepository; + + public List findAllByFestivalId(Long festivalId) { + return adminStageV1QueryDslRepository.findAllByFestivalId(festivalId); + } + + public AdminStageV1Response findById(Long stageId) { + return adminStageV1QueryDslRepository.findById(stageId) + .orElseThrow(() -> new NotFoundException(ErrorCode.STAGE_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/festago/admin/domain/Admin.java b/backend/src/main/java/com/festago/admin/domain/Admin.java index a7937444e..48b09dfca 100644 --- a/backend/src/main/java/com/festago/admin/domain/Admin.java +++ b/backend/src/main/java/com/festago/admin/domain/Admin.java @@ -1,12 +1,16 @@ package com.festago.admin.domain; import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.util.Validator; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Objects; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -22,12 +26,22 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Admin extends BaseTimeEntity { + public static final String ROOT_ADMIN_NAME = "admin"; + private static final int MIN_USERNAME_LENGTH = 4; + private static final int MAX_USERNAME_LENGTH = 20; + private static final int MIN_PASSWORD_LENGTH = 4; + private static final int MAX_PASSWORD_LENGTH = 255; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @NotNull + @Size(min = MIN_USERNAME_LENGTH, max = MAX_USERNAME_LENGTH) private String username; + @NotNull + @Size(min = MIN_PASSWORD_LENGTH, max = MAX_PASSWORD_LENGTH) private String password; public Admin(String username, String password) { @@ -35,11 +49,39 @@ public Admin(String username, String password) { } public Admin(Long id, String username, String password) { + validate(username, password); this.id = id; this.username = username; this.password = password; } + public static Admin createRootAdmin(String password) { + return new Admin(ROOT_ADMIN_NAME, password); + } + + private void validate(String username, String password) { + validateUsername(username); + validatePassword(password); + } + + private void validateUsername(String username) { + String fieldName = "username"; + Validator.notBlank(username, fieldName); + Validator.minLength(username, MIN_USERNAME_LENGTH, fieldName); + Validator.maxLength(username, MAX_USERNAME_LENGTH, fieldName); + } + + private void validatePassword(String password) { + String fieldName = "password"; + Validator.notBlank(password, fieldName); + Validator.minLength(password, MIN_PASSWORD_LENGTH, fieldName); + Validator.maxLength(password, MAX_PASSWORD_LENGTH, fieldName); + } + + public boolean isRootAdmin() { + return Objects.equals(username, ROOT_ADMIN_NAME); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/festago/admin/dto/AdminFestivalResponse.java b/backend/src/main/java/com/festago/admin/dto/AdminFestivalResponse.java deleted file mode 100644 index ea59b850e..000000000 --- a/backend/src/main/java/com/festago/admin/dto/AdminFestivalResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.festago.admin.dto; - -import java.time.LocalDate; - -public record AdminFestivalResponse( - Long id, - Long schoolId, - String name, - LocalDate startDate, - LocalDate endDate, - String thumbnail) { - -} diff --git a/backend/src/main/java/com/festago/admin/dto/AdminResponse.java b/backend/src/main/java/com/festago/admin/dto/AdminResponse.java deleted file mode 100644 index 59c473a7f..000000000 --- a/backend/src/main/java/com/festago/admin/dto/AdminResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.festago.admin.dto; - -import java.util.List; - -public record AdminResponse( - List adminSchools, - List adminTickets, - List adminStageResponse, - List adminFestivalResponse) { - -} diff --git a/backend/src/main/java/com/festago/admin/dto/AdminSchoolResponse.java b/backend/src/main/java/com/festago/admin/dto/AdminSchoolResponse.java deleted file mode 100644 index 6a898ba24..000000000 --- a/backend/src/main/java/com/festago/admin/dto/AdminSchoolResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.festago.admin.dto; - -import com.festago.school.domain.School; - -public record AdminSchoolResponse( - Long id, - String domain, - String name) { - - public static AdminSchoolResponse from(School school) { - return new AdminSchoolResponse( - school.getId(), - school.getDomain(), - school.getName() - ); - } -} diff --git a/backend/src/main/java/com/festago/admin/dto/AdminStageResponse.java b/backend/src/main/java/com/festago/admin/dto/AdminStageResponse.java deleted file mode 100644 index ab568d4c2..000000000 --- a/backend/src/main/java/com/festago/admin/dto/AdminStageResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.festago.admin.dto; - -import java.time.LocalDateTime; -import java.util.List; - -public record AdminStageResponse( - Long id, - Long festivalId, - LocalDateTime startTime, - String lineUp, - List ticketId) { - -} diff --git a/backend/src/main/java/com/festago/admin/dto/AdminTicketResponse.java b/backend/src/main/java/com/festago/admin/dto/AdminTicketResponse.java deleted file mode 100644 index 88c93754d..000000000 --- a/backend/src/main/java/com/festago/admin/dto/AdminTicketResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.festago.admin.dto; - -import com.festago.ticket.domain.TicketType; -import java.time.LocalDateTime; -import java.util.Map; - -public record AdminTicketResponse( - Long id, - Long stageId, - TicketType ticketType, - Integer totalAmount, - Integer reservedAmount, - Map entryTimeAmount) { - -} diff --git a/backend/src/main/java/com/festago/admin/dto/artist/AdminArtistV1Response.java b/backend/src/main/java/com/festago/admin/dto/artist/AdminArtistV1Response.java new file mode 100644 index 000000000..013fd9046 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/artist/AdminArtistV1Response.java @@ -0,0 +1,18 @@ +package com.festago.admin.dto.artist; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; + +public record AdminArtistV1Response( + Long id, + String name, + String profileImageUrl, + String backgroundImageUrl, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + @QueryProjection + public AdminArtistV1Response { + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/artist/ArtistV1CreateRequest.java b/backend/src/main/java/com/festago/admin/dto/artist/ArtistV1CreateRequest.java new file mode 100644 index 000000000..f59c5d30b --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/artist/ArtistV1CreateRequest.java @@ -0,0 +1,23 @@ +package com.festago.admin.dto.artist; + +import com.festago.artist.dto.command.ArtistCreateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; + +public record ArtistV1CreateRequest( + @NotBlank + String name, + @Nullable + String profileImageUrl, + @Nullable + String backgroundImageUrl +) { + + public ArtistCreateCommand toCommand() { + return ArtistCreateCommand.builder() + .name(name) + .profileImageUrl(profileImageUrl) + .backgroundImageUrl(backgroundImageUrl) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/artist/ArtistV1UpdateRequest.java b/backend/src/main/java/com/festago/admin/dto/artist/ArtistV1UpdateRequest.java new file mode 100644 index 000000000..46a51b4bc --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/artist/ArtistV1UpdateRequest.java @@ -0,0 +1,23 @@ +package com.festago.admin.dto.artist; + +import com.festago.artist.dto.command.ArtistUpdateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; + +public record ArtistV1UpdateRequest( + @NotBlank + String name, + @Nullable + String profileImageUrl, + @Nullable + String backgroundImageUrl +) { + + public ArtistUpdateCommand toCommand() { + return ArtistUpdateCommand.builder() + .name(name) + .profileImageUrl(profileImageUrl) + .backgroundImageUrl(backgroundImageUrl) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/festival/AdminFestivalDetailV1Response.java b/backend/src/main/java/com/festago/admin/dto/festival/AdminFestivalDetailV1Response.java new file mode 100644 index 000000000..4f308e51f --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/festival/AdminFestivalDetailV1Response.java @@ -0,0 +1,22 @@ +package com.festago.admin.dto.festival; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record AdminFestivalDetailV1Response( + Long id, + String name, + Long schoolId, + String schoolName, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + @QueryProjection + public AdminFestivalDetailV1Response { + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/festival/AdminFestivalV1Response.java b/backend/src/main/java/com/festago/admin/dto/festival/AdminFestivalV1Response.java new file mode 100644 index 000000000..3ddcf1df6 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/festival/AdminFestivalV1Response.java @@ -0,0 +1,18 @@ +package com.festago.admin.dto.festival; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; + +public record AdminFestivalV1Response( + Long id, + String name, + String schoolName, + LocalDate startDate, + LocalDate endDate, + long stageCount +) { + + @QueryProjection + public AdminFestivalV1Response { + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/festival/FestivalV1CreateRequest.java b/backend/src/main/java/com/festago/admin/dto/festival/FestivalV1CreateRequest.java new file mode 100644 index 000000000..90d1022d5 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/festival/FestivalV1CreateRequest.java @@ -0,0 +1,38 @@ +package com.festago.admin.dto.festival; + +import com.festago.festival.dto.command.FestivalCreateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +public record FestivalV1CreateRequest( + @NotBlank + String name, + + @NotNull + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + + @NotNull + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + + @Nullable + String posterImageUrl, + + @NotNull + Long schoolId +) { + + public FestivalCreateCommand toCommand() { + return FestivalCreateCommand.builder() + .name(name) + .startDate(startDate) + .endDate(endDate) + .posterImageUrl(posterImageUrl) + .schoolId(schoolId) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/festival/FestivalV1UpdateRequest.java b/backend/src/main/java/com/festago/admin/dto/festival/FestivalV1UpdateRequest.java new file mode 100644 index 000000000..26c37b68e --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/festival/FestivalV1UpdateRequest.java @@ -0,0 +1,34 @@ +package com.festago.admin.dto.festival; + +import com.festago.festival.dto.command.FestivalUpdateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +public record FestivalV1UpdateRequest( + @NotBlank + String name, + + @NotNull + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + + @NotNull + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + + @Nullable + String posterImageUrl +) { + + public FestivalUpdateCommand toCommand() { + return FestivalUpdateCommand.builder() + .name(name) + .startDate(startDate) + .endDate(endDate) + .posterImageUrl(posterImageUrl) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/queryinfo/QueryInfoRenewalFestivalPeriodV1Request.java b/backend/src/main/java/com/festago/admin/dto/queryinfo/QueryInfoRenewalFestivalPeriodV1Request.java new file mode 100644 index 000000000..a35688d42 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/queryinfo/QueryInfoRenewalFestivalPeriodV1Request.java @@ -0,0 +1,11 @@ +package com.festago.admin.dto.queryinfo; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record QueryInfoRenewalFestivalPeriodV1Request( + @NotNull LocalDate to, + @NotNull LocalDate end +) { + +} diff --git a/backend/src/main/java/com/festago/admin/dto/school/AdminSchoolV1Response.java b/backend/src/main/java/com/festago/admin/dto/school/AdminSchoolV1Response.java new file mode 100644 index 000000000..06a6309bd --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/school/AdminSchoolV1Response.java @@ -0,0 +1,22 @@ +package com.festago.admin.dto.school; + +import com.festago.school.domain.SchoolRegion; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; + +public record AdminSchoolV1Response( + Long id, + String domain, + String name, + SchoolRegion region, + String logoUrl, + String backgroundImageUrl, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + @QueryProjection + public AdminSchoolV1Response { + // for QueryProjection + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/school/SchoolV1CreateRequest.java b/backend/src/main/java/com/festago/admin/dto/school/SchoolV1CreateRequest.java new file mode 100644 index 000000000..b2e2d9318 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/school/SchoolV1CreateRequest.java @@ -0,0 +1,33 @@ +package com.festago.admin.dto.school; + +import com.festago.school.domain.SchoolRegion; +import com.festago.school.dto.command.SchoolCreateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record SchoolV1CreateRequest( + @NotBlank + String name, + @NotBlank + String domain, + @NotNull + SchoolRegion region, + @Nullable + String logoUrl, + @Nullable + String backgroundImageUrl +) { + + public SchoolCreateCommand toCommand() { + return SchoolCreateCommand.builder() + .name(name) + .domain(domain) + .region(region) + .logoUrl(logoUrl) + .backgroundImageUrl(backgroundImageUrl) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/school/SchoolV1UpdateRequest.java b/backend/src/main/java/com/festago/admin/dto/school/SchoolV1UpdateRequest.java new file mode 100644 index 000000000..14c1737c0 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/school/SchoolV1UpdateRequest.java @@ -0,0 +1,33 @@ +package com.festago.admin.dto.school; + +import com.festago.school.domain.SchoolRegion; +import com.festago.school.dto.command.SchoolUpdateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record SchoolV1UpdateRequest( + @NotBlank + String name, + @NotBlank + String domain, + @NotNull + SchoolRegion region, + @Nullable + String logoUrl, + @Nullable + String backgroundImageUrl +) { + + public SchoolUpdateCommand toCommand() { + return SchoolUpdateCommand.builder() + .name(name) + .domain(domain) + .region(region) + .logoUrl(logoUrl) + .backgroundImageUrl(backgroundImageUrl) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/socialmedia/AdminSocialMediaV1Response.java b/backend/src/main/java/com/festago/admin/dto/socialmedia/AdminSocialMediaV1Response.java new file mode 100644 index 000000000..5916ee3b5 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/socialmedia/AdminSocialMediaV1Response.java @@ -0,0 +1,20 @@ +package com.festago.admin.dto.socialmedia; + +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMediaType; +import com.querydsl.core.annotations.QueryProjection; + +public record AdminSocialMediaV1Response( + Long id, + Long ownerId, + OwnerType ownerType, + SocialMediaType socialMediaType, + String name, + String logoUrl, + String url +) { + + @QueryProjection + public AdminSocialMediaV1Response { + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/socialmedia/SocialMediaCreateV1Request.java b/backend/src/main/java/com/festago/admin/dto/socialmedia/SocialMediaCreateV1Request.java new file mode 100644 index 000000000..054f109a6 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/socialmedia/SocialMediaCreateV1Request.java @@ -0,0 +1,37 @@ +package com.festago.admin.dto.socialmedia; + +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.dto.command.SocialMediaCreateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record SocialMediaCreateV1Request( + @NotNull + Long ownerId, + @NotNull + OwnerType ownerType, + @NotNull + SocialMediaType socialMediaType, + @NotBlank + String name, + @Nullable + String logoUrl, + @NotBlank + String url +) { + + public SocialMediaCreateCommand toCommand() { + return SocialMediaCreateCommand.builder() + .ownerId(ownerId) + .ownerType(ownerType) + .socialMediaType(socialMediaType) + .name(name) + .logoUrl(logoUrl) + .url(url) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/socialmedia/SocialMediaUpdateV1Request.java b/backend/src/main/java/com/festago/admin/dto/socialmedia/SocialMediaUpdateV1Request.java new file mode 100644 index 000000000..b02f9f996 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/socialmedia/SocialMediaUpdateV1Request.java @@ -0,0 +1,25 @@ +package com.festago.admin.dto.socialmedia; + +import com.festago.socialmedia.dto.command.SocialMediaUpdateCommand; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +public record SocialMediaUpdateV1Request( + @NotBlank + String name, + @Nullable + String logoUrl, + @NotBlank + String url +) { + + public SocialMediaUpdateCommand toCommand() { + return SocialMediaUpdateCommand.builder() + .name(name) + .logoUrl(logoUrl) + .url(url) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/stage/AdminStageArtistV1Response.java b/backend/src/main/java/com/festago/admin/dto/stage/AdminStageArtistV1Response.java new file mode 100644 index 000000000..a36825582 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/stage/AdminStageArtistV1Response.java @@ -0,0 +1,13 @@ +package com.festago.admin.dto.stage; + +import com.querydsl.core.annotations.QueryProjection; + +public record AdminStageArtistV1Response( + Long id, + String name +) { + + @QueryProjection + public AdminStageArtistV1Response { + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/stage/AdminStageV1Response.java b/backend/src/main/java/com/festago/admin/dto/stage/AdminStageV1Response.java new file mode 100644 index 000000000..7a1e89c7d --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/stage/AdminStageV1Response.java @@ -0,0 +1,19 @@ +package com.festago.admin.dto.stage; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; +import java.util.List; + +public record AdminStageV1Response( + Long id, + LocalDateTime startDateTime, + LocalDateTime ticketOpenTime, + List artists, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + @QueryProjection + public AdminStageV1Response { + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/stage/StageV1CreateRequest.java b/backend/src/main/java/com/festago/admin/dto/stage/StageV1CreateRequest.java new file mode 100644 index 000000000..d862d498a --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/stage/StageV1CreateRequest.java @@ -0,0 +1,30 @@ +package com.festago.admin.dto.stage; + +import com.festago.stage.dto.command.StageCreateCommand; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; + +public record StageV1CreateRequest( + @NotNull + Long festivalId, + @NotNull + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startTime, + @NotNull + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime ticketOpenTime, + @NotNull + List artistIds +) { + + public StageCreateCommand toCommand() { + return StageCreateCommand.builder() + .festivalId(festivalId) + .startTime(startTime) + .ticketOpenTime(ticketOpenTime) + .artistIds(artistIds) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/stage/StageV1UpdateRequest.java b/backend/src/main/java/com/festago/admin/dto/stage/StageV1UpdateRequest.java new file mode 100644 index 000000000..ce0b0f361 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/stage/StageV1UpdateRequest.java @@ -0,0 +1,27 @@ +package com.festago.admin.dto.stage; + +import com.festago.stage.dto.command.StageUpdateCommand; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; + +public record StageV1UpdateRequest( + @NotNull + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startTime, + @NotNull + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime ticketOpenTime, + @NotNull + List artistIds +) { + + public StageUpdateCommand toCommand() { + return StageUpdateCommand.builder() + .startTime(startTime) + .ticketOpenTime(ticketOpenTime) + .artistIds(artistIds) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/dto/upload/AdminDeleteAbandonedPeriodUploadFileV1Request.java b/backend/src/main/java/com/festago/admin/dto/upload/AdminDeleteAbandonedPeriodUploadFileV1Request.java new file mode 100644 index 000000000..014e8c2f8 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/upload/AdminDeleteAbandonedPeriodUploadFileV1Request.java @@ -0,0 +1,11 @@ +package com.festago.admin.dto.upload; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +public record AdminDeleteAbandonedPeriodUploadFileV1Request( + @NotNull LocalDateTime startTime, + @NotNull LocalDateTime endTime +) { + +} diff --git a/backend/src/main/java/com/festago/admin/dto/upload/AdminImageUploadV1Response.java b/backend/src/main/java/com/festago/admin/dto/upload/AdminImageUploadV1Response.java new file mode 100644 index 000000000..651d338e4 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/dto/upload/AdminImageUploadV1Response.java @@ -0,0 +1,9 @@ +package com.festago.admin.dto.upload; + +import java.net.URI; + +public record AdminImageUploadV1Response( + URI uploadUri +) { + +} diff --git a/backend/src/main/java/com/festago/admin/infrastructure/ActuatorProxyClient.java b/backend/src/main/java/com/festago/admin/infrastructure/ActuatorProxyClient.java new file mode 100644 index 000000000..dc5741e01 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/infrastructure/ActuatorProxyClient.java @@ -0,0 +1,28 @@ +package com.festago.admin.infrastructure; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class ActuatorProxyClient { + + private final RestTemplate restTemplate; + private final int port; + + public ActuatorProxyClient( + @Value("${management.server.port}") int port, + RestTemplateBuilder restTemplateBuilder + ) { + this.restTemplate = restTemplateBuilder + .errorHandler(new AdminActuatorProxyErrorHandler()) + .build(); + this.port = port; + } + + public ResponseEntity request(String path) { + return restTemplate.getForEntity("http://localhost:" + port + "/actuator/" + path, String.class); + } +} diff --git a/backend/src/main/java/com/festago/admin/infrastructure/AdminActuatorProxyErrorHandler.java b/backend/src/main/java/com/festago/admin/infrastructure/AdminActuatorProxyErrorHandler.java new file mode 100644 index 000000000..c907edadf --- /dev/null +++ b/backend/src/main/java/com/festago/admin/infrastructure/AdminActuatorProxyErrorHandler.java @@ -0,0 +1,21 @@ +package com.festago.admin.infrastructure; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.NotFoundException; +import java.io.IOException; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.DefaultResponseErrorHandler; + +public class AdminActuatorProxyErrorHandler extends DefaultResponseErrorHandler { + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + HttpStatusCode statusCode = response.getStatusCode(); + if (statusCode.is4xxClientError()) { + throw new NotFoundException(ErrorCode.ACTUATOR_NOT_FOUND); + } + throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/AdminActuatorController.java b/backend/src/main/java/com/festago/admin/presentation/AdminActuatorController.java new file mode 100644 index 000000000..e1331d2d6 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/AdminActuatorController.java @@ -0,0 +1,24 @@ +package com.festago.admin.presentation; + +import com.festago.admin.application.AdminActuatorProxyService; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Hidden +@RestController +@RequestMapping("/admin/api/actuator") +@RequiredArgsConstructor +public class AdminActuatorController { + + private final AdminActuatorProxyService adminActuatorProxyService; + + @GetMapping("/{path}") + public ResponseEntity getActuator(@PathVariable String path) { + return adminActuatorProxyService.request(path); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/AdminController.java b/backend/src/main/java/com/festago/admin/presentation/AdminController.java new file mode 100644 index 000000000..cab9f2573 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/AdminController.java @@ -0,0 +1,45 @@ +package com.festago.admin.presentation; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import io.swagger.v3.oas.annotations.Hidden; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.info.BuildProperties; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api") +@Hidden +@RequiredArgsConstructor +public class AdminController { + + private final Optional properties; + + @GetMapping("/version") + public ResponseEntity getVersion() { + return properties.map(it -> ResponseEntity.ok(it.getTime().atZone(ZoneId.of("Asia/Seoul")).toString())) + .orElseGet(() -> ResponseEntity.ok().body(LocalDateTime.now().toString())); + } + + @GetMapping("/error") + public ResponseEntity getError() { + throw new IllegalArgumentException("테스트용 에러입니다."); + } + + @GetMapping("/warn") + public ResponseEntity getWarn() { + throw new InternalServerException(ErrorCode.FOR_TEST_ERROR); + } + + @GetMapping("/info") + public ResponseEntity getInfo() { + throw new BadRequestException(ErrorCode.FOR_TEST_ERROR); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminArtistV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminArtistV1Controller.java new file mode 100644 index 000000000..51bc30488 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminArtistV1Controller.java @@ -0,0 +1,76 @@ +package com.festago.admin.presentation.v1; + +import com.festago.admin.application.AdminArtistV1QueryService; +import com.festago.admin.dto.artist.AdminArtistV1Response; +import com.festago.admin.dto.artist.ArtistV1CreateRequest; +import com.festago.admin.dto.artist.ArtistV1UpdateRequest; +import com.festago.artist.application.ArtistCommandService; +import com.festago.common.aop.ValidPageable; +import com.festago.common.querydsl.SearchCondition; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api/v1/artists") +@RequiredArgsConstructor +@Hidden +public class AdminArtistV1Controller { + + private final ArtistCommandService artistCommandService; + private final AdminArtistV1QueryService artistV1QueryService; + + @PostMapping + public ResponseEntity create(@RequestBody @Valid ArtistV1CreateRequest request) { + Long artistId = artistCommandService.save(request.toCommand()); + return ResponseEntity.created(URI.create("/admin/api/v1/artists/" + artistId)) + .build(); + } + + @PutMapping("/{artistId}") + public ResponseEntity update( + @RequestBody @Valid ArtistV1UpdateRequest request, + @PathVariable Long artistId + ) { + artistCommandService.update(request.toCommand(), artistId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{artistId}") + public ResponseEntity delete(@PathVariable Long artistId) { + artistCommandService.delete(artistId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{artistId}") + public ResponseEntity findById(@PathVariable Long artistId) { + return ResponseEntity.ok() + .body(artistV1QueryService.findById(artistId)); + } + + @GetMapping + @ValidPageable(maxSize = 50) + public ResponseEntity> findAll( + @RequestParam(defaultValue = "") String searchFilter, + @RequestParam(defaultValue = "") String searchKeyword, + @PageableDefault(size = 10) Pageable pageable + ) { + return ResponseEntity.ok() + .body(artistV1QueryService.findAll(new SearchCondition(searchFilter, searchKeyword, pageable))); + } +} + diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java new file mode 100644 index 000000000..cfa7e7449 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminFestivalV1Controller.java @@ -0,0 +1,96 @@ +package com.festago.admin.presentation.v1; + +import com.festago.admin.application.AdminFestivalV1QueryService; +import com.festago.admin.application.AdminStageV1QueryService; +import com.festago.admin.dto.festival.AdminFestivalDetailV1Response; +import com.festago.admin.dto.festival.AdminFestivalV1Response; +import com.festago.admin.dto.festival.FestivalV1CreateRequest; +import com.festago.admin.dto.festival.FestivalV1UpdateRequest; +import com.festago.admin.dto.stage.AdminStageV1Response; +import com.festago.common.aop.ValidPageable; +import com.festago.common.querydsl.SearchCondition; +import com.festago.festival.application.command.FestivalCommandFacadeService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api/v1/festivals") +@RequiredArgsConstructor +@Hidden +public class AdminFestivalV1Controller { + + private final AdminStageV1QueryService adminStageV1QueryService; + private final AdminFestivalV1QueryService adminFestivalV1QueryService; + private final FestivalCommandFacadeService festivalCommandFacadeService; + + @ValidPageable(maxSize = 50) + @GetMapping + public ResponseEntity> findAll( + @RequestParam(defaultValue = "") String searchFilter, + @RequestParam(defaultValue = "") String searchKeyword, + @PageableDefault(size = 10) Pageable pageable + ) { + return ResponseEntity.ok() + .body(adminFestivalV1QueryService.findAll(new SearchCondition(searchFilter, searchKeyword, pageable))); + } + + @GetMapping("/{festivalId}") + public ResponseEntity findDetail( + @PathVariable Long festivalId + ) { + return ResponseEntity.ok() + .body(adminFestivalV1QueryService.findDetail(festivalId)); + } + + @GetMapping("/{festivalId}/stages") + public ResponseEntity> findAllStagesByFestivalId( + @PathVariable Long festivalId + ) { + return ResponseEntity.ok() + .body(adminStageV1QueryService.findAllByFestivalId(festivalId)); + } + + @PostMapping + public ResponseEntity createFestival( + @RequestBody @Valid FestivalV1CreateRequest request + ) { + Long festivalId = festivalCommandFacadeService.createFestival(request.toCommand()); + return ResponseEntity.created(URI.create("/admin/api/v1/festivals/" + festivalId)) + .build(); + } + + @PatchMapping("/{festivalId}") + public ResponseEntity updateFestival( + @PathVariable Long festivalId, + @RequestBody @Valid FestivalV1UpdateRequest request + ) { + festivalCommandFacadeService.updateFestival(festivalId, request.toCommand()); + return ResponseEntity.ok() + .build(); + } + + @DeleteMapping("/{festivalId}") + public ResponseEntity deleteFestival( + @PathVariable Long festivalId + ) { + festivalCommandFacadeService.deleteFestival(festivalId); + return ResponseEntity.noContent() + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminMockDataV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminMockDataV1Controller.java new file mode 100644 index 000000000..b839ee913 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminMockDataV1Controller.java @@ -0,0 +1,26 @@ +package com.festago.admin.presentation.v1; + +import com.festago.mock.application.MockDataService; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +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.RestController; + +@Profile({"!prod"}) +@RestController +@RequestMapping("/admin/api/v1/mock-data") +@RequiredArgsConstructor +@Hidden +public class AdminMockDataV1Controller { + + private final MockDataService mockDataService; + + @PostMapping("/festivals") + public ResponseEntity generateMockFestivals() { + mockDataService.makeMockFestivals(); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminQueryInfoRenewalV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminQueryInfoRenewalV1Controller.java new file mode 100644 index 000000000..24bb37a4a --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminQueryInfoRenewalV1Controller.java @@ -0,0 +1,38 @@ +package com.festago.admin.presentation.v1; + +import com.festago.admin.application.AdminQueryInfoRenewalService; +import com.festago.admin.dto.queryinfo.QueryInfoRenewalFestivalPeriodV1Request; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api/v1/query-info/renewal") +@RequiredArgsConstructor +@Hidden +public class AdminQueryInfoRenewalV1Controller { + + private final AdminQueryInfoRenewalService queryInfoRenewalService; + + @PostMapping("/festival-id/{festivalId}") + public ResponseEntity renewalByFestivalId( + @PathVariable Long festivalId + ) { + queryInfoRenewalService.renewalByFestivalId(festivalId); + return ResponseEntity.ok().build(); + } + + @PostMapping("/festival-period") + public ResponseEntity renewalByFestivalStartDatePeriod( + @RequestBody @Valid QueryInfoRenewalFestivalPeriodV1Request request + ) { + queryInfoRenewalService.renewalByFestivalStartDatePeriod(request.to(), request.end()); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java new file mode 100644 index 000000000..0ca449fdf --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSchoolV1Controller.java @@ -0,0 +1,85 @@ +package com.festago.admin.presentation.v1; + +import com.festago.admin.application.AdminSchoolV1QueryService; +import com.festago.admin.dto.school.AdminSchoolV1Response; +import com.festago.admin.dto.school.SchoolV1CreateRequest; +import com.festago.admin.dto.school.SchoolV1UpdateRequest; +import com.festago.common.aop.ValidPageable; +import com.festago.common.querydsl.SearchCondition; +import com.festago.school.application.SchoolCommandService; +import com.festago.school.application.SchoolDeleteService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api/v1/schools") +@RequiredArgsConstructor +@Hidden +public class AdminSchoolV1Controller { + + private final SchoolCommandService schoolCommandService; + private final SchoolDeleteService schoolDeleteService; + private final AdminSchoolV1QueryService schoolQueryService; + + @PostMapping + public ResponseEntity createSchool( + @RequestBody @Valid SchoolV1CreateRequest request + ) { + Long schoolId = schoolCommandService.createSchool(request.toCommand()); + return ResponseEntity.created(URI.create("/api/v1/schools/" + schoolId)) + .build(); + } + + @PatchMapping("/{schoolId}") + public ResponseEntity updateSchool( + @PathVariable Long schoolId, + @RequestBody @Valid SchoolV1UpdateRequest request + ) { + schoolCommandService.updateSchool(schoolId, request.toCommand()); + return ResponseEntity.ok() + .build(); + } + + @DeleteMapping("/{schoolId}") + public ResponseEntity deleteSchool( + @PathVariable Long schoolId + ) { + schoolDeleteService.deleteSchool(schoolId); + return ResponseEntity.noContent() + .build(); + } + + @GetMapping + @ValidPageable(maxSize = 50) + public ResponseEntity> findAllSchools( + @RequestParam(defaultValue = "") String searchFilter, + @RequestParam(defaultValue = "") String searchKeyword, + @PageableDefault(size = 10) Pageable pageable + ) { + return ResponseEntity.ok() + .body(schoolQueryService.findAll(new SearchCondition(searchFilter, searchKeyword, pageable))); + } + + @GetMapping("/{schoolId}") + public ResponseEntity findSchoolById( + @PathVariable Long schoolId + ) { + return ResponseEntity.ok() + .body(schoolQueryService.findById(schoolId)); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminSocialMediaV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSocialMediaV1Controller.java new file mode 100644 index 000000000..8b4d974d6 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminSocialMediaV1Controller.java @@ -0,0 +1,76 @@ +package com.festago.admin.presentation.v1; + +import com.festago.admin.application.AdminSocialMediaV1QueryService; +import com.festago.admin.dto.socialmedia.AdminSocialMediaV1Response; +import com.festago.admin.dto.socialmedia.SocialMediaCreateV1Request; +import com.festago.admin.dto.socialmedia.SocialMediaUpdateV1Request; +import com.festago.socialmedia.application.SocialMediaCommandService; +import com.festago.socialmedia.domain.OwnerType; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api/v1/socialmedias") +@RequiredArgsConstructor +@Hidden +public class AdminSocialMediaV1Controller { + + private final SocialMediaCommandService socialMediaCommandService; + private final AdminSocialMediaV1QueryService adminSocialMediaV1QueryService; + + @GetMapping + public ResponseEntity> findByOwnerIdAndOwnerType( + @RequestParam Long ownerId, + @RequestParam OwnerType ownerType + ) { + return ResponseEntity.ok() + .body(adminSocialMediaV1QueryService.findByOwnerIdAndOwnerType(ownerId, ownerType)); + } + + @GetMapping("/{socialMediaId}") + public ResponseEntity findById( + @PathVariable Long socialMediaId + ) { + return ResponseEntity.ok() + .body(adminSocialMediaV1QueryService.findById(socialMediaId)); + } + + @PostMapping + public ResponseEntity createSocialMedia( + @RequestBody @Valid SocialMediaCreateV1Request request + ) { + socialMediaCommandService.createSocialMedia(request.toCommand()); + return ResponseEntity.ok() + .build(); + } + + @PatchMapping("/{socialMediaId}") + public ResponseEntity updateSocialMedia( + @PathVariable Long socialMediaId, + @RequestBody @Valid SocialMediaUpdateV1Request request + ) { + socialMediaCommandService.updateSocialMedia(socialMediaId, request.toCommand()); + return ResponseEntity.ok() + .build(); + } + + @DeleteMapping("/{socialMediaId}") + public ResponseEntity deleteSocialMedia( + @PathVariable Long socialMediaId + ) { + socialMediaCommandService.deleteSocialMedia(socialMediaId); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminStageV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminStageV1Controller.java new file mode 100644 index 000000000..746d1f3f6 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminStageV1Controller.java @@ -0,0 +1,66 @@ +package com.festago.admin.presentation.v1; + +import com.festago.admin.application.AdminStageV1QueryService; +import com.festago.admin.dto.stage.AdminStageV1Response; +import com.festago.admin.dto.stage.StageV1CreateRequest; +import com.festago.admin.dto.stage.StageV1UpdateRequest; +import com.festago.stage.application.command.StageCommandFacadeService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api/v1/stages") +@RequiredArgsConstructor +@Hidden +public class AdminStageV1Controller { + + private final StageCommandFacadeService stageCommandFacadeService; + private final AdminStageV1QueryService adminStageV1QueryService; + + @GetMapping("/{stageId}") + public ResponseEntity findById( + @PathVariable Long stageId + ) { + return ResponseEntity.ok() + .body(adminStageV1QueryService.findById(stageId)); + } + + @PostMapping + public ResponseEntity createStage( + @RequestBody @Valid StageV1CreateRequest request + ) { + Long stageId = stageCommandFacadeService.createStage(request.toCommand()); + return ResponseEntity.created(URI.create("/admin/api/v1/stages/" + stageId)) + .build(); + } + + @PatchMapping("/{stageId}") + public ResponseEntity updateStage( + @PathVariable Long stageId, + @RequestBody @Valid StageV1UpdateRequest request + ) { + stageCommandFacadeService.updateStage(stageId, request.toCommand()); + return ResponseEntity.ok() + .build(); + } + + @DeleteMapping("/{stageId}") + public ResponseEntity deleteStage( + @PathVariable Long stageId + ) { + stageCommandFacadeService.deleteStage(stageId); + return ResponseEntity.noContent() + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminUploadFileDeleteV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminUploadFileDeleteV1Controller.java new file mode 100644 index 000000000..2ca48930c --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminUploadFileDeleteV1Controller.java @@ -0,0 +1,37 @@ +package com.festago.admin.presentation.v1; + +import com.festago.admin.dto.upload.AdminDeleteAbandonedPeriodUploadFileV1Request; +import com.festago.upload.application.UploadFileDeleteService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api/v1/upload/delete") +@RequiredArgsConstructor +@Hidden +public class AdminUploadFileDeleteV1Controller { + + private final UploadFileDeleteService uploadFileDeleteService; + + @DeleteMapping("/abandoned-period") + public ResponseEntity deleteAbandonedWithPeriod( + @RequestBody @Valid AdminDeleteAbandonedPeriodUploadFileV1Request request + ) { + uploadFileDeleteService.deleteAbandonedStatusWithPeriod(request.startTime(), request.endTime()); + return ResponseEntity.ok() + .build(); + } + + @DeleteMapping("/old-uploaded") + public ResponseEntity deleteOldUploaded() { + uploadFileDeleteService.deleteOldUploadedStatus(); + return ResponseEntity.ok() + .build(); + } +} diff --git a/backend/src/main/java/com/festago/admin/presentation/v1/AdminUploadImageV1Controller.java b/backend/src/main/java/com/festago/admin/presentation/v1/AdminUploadImageV1Controller.java new file mode 100644 index 000000000..3530aae0e --- /dev/null +++ b/backend/src/main/java/com/festago/admin/presentation/v1/AdminUploadImageV1Controller.java @@ -0,0 +1,35 @@ +package com.festago.admin.presentation.v1; + +import com.festago.admin.dto.upload.AdminImageUploadV1Response; +import com.festago.upload.application.ImageFileUploadService; +import com.festago.upload.domain.FileOwnerType; +import com.festago.upload.dto.FileUploadResult; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +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.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/admin/api/v1/upload/images") +@RequiredArgsConstructor +@Hidden +public class AdminUploadImageV1Controller { + + private final ImageFileUploadService imageFileUploadService; + + @PostMapping + public ResponseEntity uploadImage( + @RequestPart MultipartFile image, + @RequestParam(required = false) Long ownerId, + @RequestParam(required = false) FileOwnerType ownerType + ) { + FileUploadResult result = imageFileUploadService.upload(image, ownerId, ownerType); + return ResponseEntity.ok() + .body(new AdminImageUploadV1Response(result.uploadUri())); + } +} diff --git a/backend/src/main/java/com/festago/admin/repository/AdminArtistV1QueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminArtistV1QueryDslRepository.java new file mode 100644 index 000000000..d0e2a89d4 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/repository/AdminArtistV1QueryDslRepository.java @@ -0,0 +1,89 @@ +package com.festago.admin.repository; + +import static com.festago.artist.domain.QArtist.artist; + +import com.festago.admin.dto.artist.AdminArtistV1Response; +import com.festago.admin.dto.artist.QAdminArtistV1Response; +import com.festago.artist.domain.Artist; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.common.querydsl.SearchCondition; +import com.querydsl.core.types.dsl.BooleanExpression; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +@Repository +public class AdminArtistV1QueryDslRepository extends QueryDslRepositorySupport { + + public AdminArtistV1QueryDslRepository() { + super(Artist.class); + } + + public Page findAll(SearchCondition searchCondition) { + Pageable pageable = searchCondition.pageable(); + return applyPagination(pageable, + queryFactory -> queryFactory.select( + new QAdminArtistV1Response( + artist.id, + artist.name, + artist.profileImage, + artist.backgroundImageUrl, + artist.createdAt, + artist.updatedAt + )) + .from(artist) + .where(applySearch(searchCondition)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()), + queryFactory -> queryFactory.select(artist.count()) + .where(applySearch(searchCondition)) + .from(artist)); + } + + private BooleanExpression applySearch(SearchCondition searchCondition) { + String searchFilter = searchCondition.searchFilter(); + String searchKeyword = searchCondition.searchKeyword(); + return switch (searchFilter) { + case "id" -> eqId(searchKeyword); + case "name" -> searchKeyword.length() == 1 ? eqName(searchKeyword) : containsName(searchKeyword); + default -> null; + }; + } + + private BooleanExpression eqId(String id) { + if (StringUtils.hasText(id)) { + return artist.id.stringValue().eq(id); + } + return null; + } + + private BooleanExpression eqName(String name) { + if (StringUtils.hasText(name)) { + return artist.name.eq(name); + } + return null; + } + + private BooleanExpression containsName(String name) { + if (StringUtils.hasText(name)) { + return artist.name.contains(name); + } + return null; + } + + public Optional findById(Long artistId) { + return fetchOne(queryFactory -> queryFactory.select( + new QAdminArtistV1Response( + artist.id, + artist.name, + artist.profileImage, + artist.backgroundImageUrl, + artist.createdAt, + artist.updatedAt + )) + .from(artist) + .where(artist.id.eq(artistId))); + } +} diff --git a/backend/src/main/java/com/festago/admin/repository/AdminFestivalDetailV1QueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminFestivalDetailV1QueryDslRepository.java new file mode 100644 index 000000000..2f722d763 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/repository/AdminFestivalDetailV1QueryDslRepository.java @@ -0,0 +1,38 @@ +package com.festago.admin.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.school.domain.QSchool.school; + +import com.festago.admin.dto.festival.AdminFestivalDetailV1Response; +import com.festago.admin.dto.festival.QAdminFestivalDetailV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public class AdminFestivalDetailV1QueryDslRepository extends QueryDslRepositorySupport { + + public AdminFestivalDetailV1QueryDslRepository() { + super(Festival.class); + } + + public Optional findDetail(Long festivalId) { + return fetchOne(query -> query.select(new QAdminFestivalDetailV1Response( + festival.id, + festival.name, + school.id, + school.name, + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + festival.posterImageUrl, + festival.createdAt, + festival.updatedAt + ) + ) + .from(festival) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .where(festival.id.eq(festivalId)) + ); + } +} diff --git a/backend/src/main/java/com/festago/admin/repository/AdminFestivalIdResolverQueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminFestivalIdResolverQueryDslRepository.java new file mode 100644 index 000000000..da40e00c5 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/repository/AdminFestivalIdResolverQueryDslRepository.java @@ -0,0 +1,25 @@ +package com.festago.admin.repository; + +import static com.festago.festival.domain.QFestival.festival; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; +import java.time.LocalDate; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class AdminFestivalIdResolverQueryDslRepository extends QueryDslRepositorySupport { + + public AdminFestivalIdResolverQueryDslRepository() { + super(Festival.class); + } + + public List findFestivalIdsByStartDatePeriod(LocalDate to, LocalDate end) { + return select(festival.id) + .from(festival) + .where(festival.festivalDuration.startDate.goe(to) + .and(festival.festivalDuration.startDate.loe(end))) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/admin/repository/AdminFestivalV1QueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminFestivalV1QueryDslRepository.java new file mode 100644 index 000000000..fe7b6df76 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/repository/AdminFestivalV1QueryDslRepository.java @@ -0,0 +1,97 @@ +package com.festago.admin.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.school.domain.QSchool.school; +import static com.festago.stage.domain.QStage.stage; + +import com.festago.admin.dto.festival.AdminFestivalV1Response; +import com.festago.admin.dto.festival.QAdminFestivalV1Response; +import com.festago.common.querydsl.OrderSpecifierUtils; +import com.festago.common.querydsl.QueryDslHelper; +import com.festago.common.querydsl.SearchCondition; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +@Repository +@RequiredArgsConstructor +public class AdminFestivalV1QueryDslRepository { + + private final QueryDslHelper queryDslHelper; + + public Page findAll(SearchCondition searchCondition) { + Pageable pageable = searchCondition.pageable(); + String searchFilter = searchCondition.searchFilter(); + String searchKeyword = searchCondition.searchKeyword(); + return queryDslHelper.applyPagination(pageable, + queryFactory -> queryFactory.select( + new QAdminFestivalV1Response( + festival.id, + festival.name, + school.name, + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + stage.count() + )) + .from(festival) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .leftJoin(stage).on(stage.festival.id.eq(festival.id)) + .where(applySearchFilter(searchFilter, searchKeyword)) + .groupBy(festival.id) + .orderBy(getOrderSpecifier(pageable.getSort())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()), + queryFactory -> queryFactory.select(festival.count()) + .from(festival) + .where(applySearchFilter(searchFilter, searchKeyword))); + } + + private BooleanExpression applySearchFilter(String searchFilter, String searchKeyword) { + return switch (searchFilter) { + case "id" -> eqId(searchKeyword); + case "name" -> containsName(searchKeyword); + case "schoolName" -> containsSchoolName(searchKeyword); + default -> null; + }; + } + + private BooleanExpression eqId(String searchKeyword) { + if (StringUtils.hasText(searchKeyword)) { + return festival.id.stringValue().eq(searchKeyword); + } + return null; + } + + private BooleanExpression containsName(String searchKeyword) { + if (StringUtils.hasText(searchKeyword)) { + return festival.name.contains(searchKeyword); + } + return null; + } + + private BooleanExpression containsSchoolName(String searchKeyword) { + if (StringUtils.hasText(searchKeyword)) { + return school.name.contains(searchKeyword); + } + return null; + } + + private OrderSpecifier getOrderSpecifier(Sort sort) { + return sort.stream() + .findFirst() + .map(it -> switch (it.getProperty()) { + case "id" -> OrderSpecifierUtils.of(it.getDirection(), festival.id); + case "name" -> OrderSpecifierUtils.of(it.getDirection(), festival.name); + case "schoolName" -> OrderSpecifierUtils.of(it.getDirection(), school.name); + case "startDate" -> OrderSpecifierUtils.of(it.getDirection(), festival.festivalDuration.startDate); + case "endDate" -> OrderSpecifierUtils.of(it.getDirection(), festival.festivalDuration.endDate); + default -> OrderSpecifierUtils.NULL; + }) + .orElse(OrderSpecifierUtils.NULL); + } +} diff --git a/backend/src/main/java/com/festago/admin/repository/AdminRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminRepository.java index 6bb333397..9806e00f9 100644 --- a/backend/src/main/java/com/festago/admin/repository/AdminRepository.java +++ b/backend/src/main/java/com/festago/admin/repository/AdminRepository.java @@ -2,9 +2,13 @@ import com.festago.admin.domain.Admin; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.Repository; -public interface AdminRepository extends JpaRepository { +public interface AdminRepository extends Repository { + + Admin save(Admin admin); + + Optional findById(Long adminId); Optional findByUsername(String username); diff --git a/backend/src/main/java/com/festago/admin/repository/AdminSchoolV1QueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminSchoolV1QueryDslRepository.java new file mode 100644 index 000000000..f0471d927 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/repository/AdminSchoolV1QueryDslRepository.java @@ -0,0 +1,102 @@ +package com.festago.admin.repository; + +import static com.festago.school.domain.QSchool.school; + +import com.festago.admin.dto.school.AdminSchoolV1Response; +import com.festago.admin.dto.school.QAdminSchoolV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.common.querydsl.SearchCondition; +import com.festago.school.domain.School; +import com.querydsl.core.types.dsl.BooleanExpression; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +@Repository +public class AdminSchoolV1QueryDslRepository extends QueryDslRepositorySupport { + + protected AdminSchoolV1QueryDslRepository() { + super(School.class); + } + + public Page findAll(SearchCondition searchCondition) { + Pageable pageable = searchCondition.pageable(); + String searchFilter = searchCondition.searchFilter(); + String searchKeyword = searchCondition.searchKeyword(); + return applyPagination(pageable, + queryFactory -> queryFactory.select( + new QAdminSchoolV1Response( + school.id, + school.domain, + school.name, + school.region, + school.logoUrl, + school.backgroundUrl, + school.createdAt, + school.updatedAt + )) + .from(school) + .where(containSearchFilter(searchFilter, searchKeyword)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()), + queryFactory -> queryFactory.select(school.count()) + .where(containSearchFilter(searchFilter, searchKeyword)) + .from(school)); + } + + private BooleanExpression containSearchFilter(String searchFilter, String searchKeyword) { + return switch (searchFilter) { + case "id" -> eqId(searchKeyword); + case "domain" -> containsDomain(searchKeyword); + case "name" -> containsName(searchKeyword); + case "region" -> eqRegion(searchKeyword); + default -> null; + }; + } + + private BooleanExpression eqId(String id) { + if (StringUtils.hasText(id)) { + return school.id.stringValue().eq(id); + } + return null; + } + + private BooleanExpression containsDomain(String domain) { + if (StringUtils.hasText(domain)) { + return school.domain.contains(domain); + } + return null; + } + + private BooleanExpression containsName(String name) { + if (StringUtils.hasText(name)) { + return school.name.contains(name); + } + return null; + } + + private BooleanExpression eqRegion(String region) { + if (StringUtils.hasText(region)) { + return school.region.stringValue().eq(region); + } + return null; + } + + public Optional findById(Long schoolId) { + return fetchOne(queryFactory -> queryFactory.select( + new QAdminSchoolV1Response( + school.id, + school.domain, + school.name, + school.region, + school.logoUrl, + school.backgroundUrl, + school.createdAt, + school.updatedAt + )) + .from(school) + .where(school.id.eq(schoolId))); + } +} diff --git a/backend/src/main/java/com/festago/admin/repository/AdminSocialMediaV1QueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminSocialMediaV1QueryDslRepository.java new file mode 100644 index 000000000..803bc3ca3 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/repository/AdminSocialMediaV1QueryDslRepository.java @@ -0,0 +1,51 @@ +package com.festago.admin.repository; + +import static com.festago.socialmedia.domain.QSocialMedia.socialMedia; + +import com.festago.admin.dto.socialmedia.AdminSocialMediaV1Response; +import com.festago.admin.dto.socialmedia.QAdminSocialMediaV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public class AdminSocialMediaV1QueryDslRepository extends QueryDslRepositorySupport { + + public AdminSocialMediaV1QueryDslRepository() { + super(SocialMedia.class); + } + + public Optional findById(Long socialMediaId) { + return fetchOne(query -> query.select(new QAdminSocialMediaV1Response( + socialMedia.id, + socialMedia.ownerId, + socialMedia.ownerType, + socialMedia.mediaType, + socialMedia.name, + socialMedia.logoUrl, + socialMedia.url + )) + .from(socialMedia) + .where(socialMedia.id.eq(socialMediaId)) + ); + } + + // SocialMedia의 도메인 특성 상 SocialMediaType 개수만큼 row 생성이 제한되기에 limit을 사용하지 않음 + public List findByOwnerIdAndOwnerType(Long ownerId, OwnerType ownerType) { + return select(new QAdminSocialMediaV1Response( + socialMedia.id, + socialMedia.ownerId, + socialMedia.ownerType, + socialMedia.mediaType, + socialMedia.name, + socialMedia.logoUrl, + socialMedia.url + )) + .from(socialMedia) + .where(socialMedia.ownerId.eq(ownerId).and(socialMedia.ownerType.eq(ownerType))) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/admin/repository/AdminStageIdResolverQueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminStageIdResolverQueryDslRepository.java new file mode 100644 index 000000000..bc94976cd --- /dev/null +++ b/backend/src/main/java/com/festago/admin/repository/AdminStageIdResolverQueryDslRepository.java @@ -0,0 +1,33 @@ +package com.festago.admin.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.stage.domain.QStage.stage; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.stage.domain.Stage; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class AdminStageIdResolverQueryDslRepository extends QueryDslRepositorySupport { + + public AdminStageIdResolverQueryDslRepository() { + super(Stage.class); + } + + public List findStageIdsByFestivalId(Long festivalId) { + return select(stage.id) + .from(stage) + .innerJoin(festival).on(festival.id.eq(stage.festival.id)) + .where(festival.id.eq(festivalId)) + .fetch(); + } + + public List findStageIdsByFestivalIdIn(List festivalIds) { + return select(stage.id) + .from(stage) + .innerJoin(festival).on(festival.id.eq(stage.festival.id)) + .where(festival.id.in(festivalIds)) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/admin/repository/AdminStageV1QueryDslRepository.java b/backend/src/main/java/com/festago/admin/repository/AdminStageV1QueryDslRepository.java new file mode 100644 index 000000000..853178894 --- /dev/null +++ b/backend/src/main/java/com/festago/admin/repository/AdminStageV1QueryDslRepository.java @@ -0,0 +1,73 @@ +package com.festago.admin.repository; + +import static com.festago.artist.domain.QArtist.artist; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.stage.domain.QStageArtist.stageArtist; +import static com.querydsl.core.group.GroupBy.groupBy; +import static com.querydsl.core.group.GroupBy.list; + +import com.festago.admin.dto.stage.AdminStageV1Response; +import com.festago.admin.dto.stage.QAdminStageArtistV1Response; +import com.festago.admin.dto.stage.QAdminStageV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.stage.domain.Stage; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public class AdminStageV1QueryDslRepository extends QueryDslRepositorySupport { + + protected AdminStageV1QueryDslRepository() { + super(Stage.class); + } + + public List findAllByFestivalId(Long festivalId) { + return selectFrom(stage) + .innerJoin(stageArtist).on(stageArtist.stageId.eq(stage.id)) + .innerJoin(artist).on(artist.id.eq(stageArtist.artistId)) + .where(stage.festival.id.eq(festivalId)) + .orderBy(stage.startTime.asc()) + .transform( + groupBy(stage.id).list( + new QAdminStageV1Response( + stage.id, + stage.startTime, + stage.ticketOpenTime, + list(new QAdminStageArtistV1Response( + artist.id, + artist.name + )), + stage.createdAt, + stage.updatedAt + ) + ) + ); + } + + public Optional findById(Long stageId) { + List response = selectFrom(stage) + .leftJoin(stageArtist).on(stageArtist.stageId.eq(stageId)) + .leftJoin(artist).on(artist.id.eq(stageArtist.artistId)) + .where(stage.id.eq(stageId)) + .transform( + groupBy(stage.id).list( + new QAdminStageV1Response( + stage.id, + stage.startTime, + stage.ticketOpenTime, + list(new QAdminStageArtistV1Response( + artist.id, + artist.name + )), + stage.createdAt, + stage.updatedAt + ) + ) + ); + if (response.isEmpty()) { + return Optional.empty(); + } + return Optional.of(response.get(0)); + } +} diff --git a/backend/src/main/java/com/festago/artist/application/ArtistCommandService.java b/backend/src/main/java/com/festago/artist/application/ArtistCommandService.java new file mode 100644 index 000000000..094535c48 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/application/ArtistCommandService.java @@ -0,0 +1,50 @@ +package com.festago.artist.application; + +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.command.ArtistCreateCommand; +import com.festago.artist.dto.command.ArtistUpdateCommand; +import com.festago.artist.dto.event.ArtistCreatedEvent; +import com.festago.artist.dto.event.ArtistDeletedEvent; +import com.festago.artist.dto.event.ArtistUpdatedEvent; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ArtistCommandService { + + private final ArtistRepository artistRepository; + private final ApplicationEventPublisher eventPublisher; + + public Long save(ArtistCreateCommand command) { + validateSave(command); + Artist artist = artistRepository.save( + new Artist(command.name(), command.profileImageUrl(), command.backgroundImageUrl()) + ); + eventPublisher.publishEvent(new ArtistCreatedEvent(artist)); + return artist.getId(); + } + + private void validateSave(ArtistCreateCommand command) { + if (artistRepository.existsByName(command.name())) { + throw new BadRequestException(ErrorCode.DUPLICATE_ARTIST_NAME); + } + } + + public void update(ArtistUpdateCommand command, Long artistId) { + Artist artist = artistRepository.getOrThrow(artistId); + artist.update(command.name(), command.profileImageUrl(), command.backgroundImageUrl()); + eventPublisher.publishEvent(new ArtistUpdatedEvent(artist)); + } + + public void delete(Long artistId) { + artistRepository.deleteById(artistId); + eventPublisher.publishEvent(new ArtistDeletedEvent(artistId)); + } +} diff --git a/backend/src/main/java/com/festago/artist/application/ArtistDetailV1QueryService.java b/backend/src/main/java/com/festago/artist/application/ArtistDetailV1QueryService.java new file mode 100644 index 000000000..63825d8d4 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/application/ArtistDetailV1QueryService.java @@ -0,0 +1,43 @@ +package com.festago.artist.application; + +import com.festago.artist.dto.ArtistDetailV1Response; +import com.festago.artist.dto.ArtistFestivalV1Response; +import com.festago.artist.repository.ArtistDetailV1QueryDslRepository; +import com.festago.artist.repository.ArtistFestivalSearchCondition; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import java.time.Clock; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistDetailV1QueryService { + + private final ArtistDetailV1QueryDslRepository artistDetailV1QueryDslRepository; + private final Clock clock; + + public ArtistDetailV1Response findArtistDetail(Long artistId) { + return artistDetailV1QueryDslRepository.findArtistDetail(artistId) + .orElseThrow(() -> new NotFoundException(ErrorCode.ARTIST_NOT_FOUND)); + } + + public Slice findArtistFestivals(Long artistId, Long lastFestivalId, + LocalDate lastStartDate, boolean isPast, + Pageable pageable) { + return artistDetailV1QueryDslRepository.findArtistFestivals(new ArtistFestivalSearchCondition( + artistId, + isPast, + lastFestivalId, + lastStartDate, + pageable, + LocalDate.now(clock) + ) + ); + } +} diff --git a/backend/src/main/java/com/festago/artist/application/ArtistSearchStageCountV1QueryService.java b/backend/src/main/java/com/festago/artist/application/ArtistSearchStageCountV1QueryService.java new file mode 100644 index 000000000..0e50e82bd --- /dev/null +++ b/backend/src/main/java/com/festago/artist/application/ArtistSearchStageCountV1QueryService.java @@ -0,0 +1,50 @@ +package com.festago.artist.application; + +import static java.util.stream.Collectors.toMap; + +import com.festago.artist.dto.ArtistSearchStageCountV1Response; +import com.festago.artist.repository.ArtistSearchV1QueryDslRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistSearchStageCountV1QueryService { + + private final ArtistSearchV1QueryDslRepository artistSearchV1QueryDslRepository; + + public Map findArtistsStageCountAfterDateTime( + List artistIds, + LocalDateTime dateTime + ) { + Map> artistToStageStartTimes = artistSearchV1QueryDslRepository.findArtistsStageScheduleAfterDateTime( + artistIds, dateTime); + LocalDate today = dateTime.toLocalDate(); + return artistIds.stream() + .collect(toMap( + Function.identity(), + artistId -> getArtistStageCount(artistId, artistToStageStartTimes, today) + )); + } + + private ArtistSearchStageCountV1Response getArtistStageCount( + Long artistId, + Map> artistToStageStartTimes, + LocalDate today + ) { + List stageStartTimes = artistToStageStartTimes.getOrDefault(artistId, Collections.emptyList()); + int countOfTodayStage = (int) stageStartTimes.stream() + .filter(it -> it.toLocalDate().equals(today)) + .count(); + int countOfPlannedStage = stageStartTimes.size() - countOfTodayStage; + return new ArtistSearchStageCountV1Response(countOfTodayStage, countOfPlannedStage); + } +} diff --git a/backend/src/main/java/com/festago/artist/application/ArtistSearchV1QueryService.java b/backend/src/main/java/com/festago/artist/application/ArtistSearchV1QueryService.java new file mode 100644 index 000000000..f21c9ba93 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/application/ArtistSearchV1QueryService.java @@ -0,0 +1,35 @@ +package com.festago.artist.application; + +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.repository.ArtistSearchV1QueryDslRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistSearchV1QueryService { + + private static final int MAX_SEARCH_COUNT = 10; + + private final ArtistSearchV1QueryDslRepository artistSearchV1QueryDslRepository; + + public List findAllByKeyword(String keyword) { + List response = getResponse(keyword); + if (response.size() >= MAX_SEARCH_COUNT) { + throw new BadRequestException(ErrorCode.BROAD_SEARCH_KEYWORD); + } + return response; + } + + private List getResponse(String keyword) { + if (keyword.length() == 1) { + return artistSearchV1QueryDslRepository.findAllByEqual(keyword); + } + return artistSearchV1QueryDslRepository.findAllByLike(keyword); + } +} diff --git a/backend/src/main/java/com/festago/artist/application/ArtistTotalSearchV1Service.java b/backend/src/main/java/com/festago/artist/application/ArtistTotalSearchV1Service.java new file mode 100644 index 000000000..46df4e9e2 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/application/ArtistTotalSearchV1Service.java @@ -0,0 +1,34 @@ +package com.festago.artist.application; + +import com.festago.artist.dto.ArtistSearchStageCountV1Response; +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.dto.ArtistTotalSearchV1Response; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistTotalSearchV1Service { + + private final ArtistSearchV1QueryService artistSearchV1QueryService; + private final ArtistSearchStageCountV1QueryService artistSearchStageCountV1QueryService; + private final Clock clock; + + public List findAllByKeyword(String keyword) { + List artists = artistSearchV1QueryService.findAllByKeyword(keyword); + List artistIds = artists.stream() + .map(ArtistSearchV1Response::id) + .toList(); + Map artistToStageCount = artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + artistIds, LocalDate.now(clock).atStartOfDay()); + return artists.stream() + .map(it -> ArtistTotalSearchV1Response.of(it, artistToStageCount.get(it.id()))) + .toList(); + } +} diff --git a/backend/src/main/java/com/festago/artist/domain/Artist.java b/backend/src/main/java/com/festago/artist/domain/Artist.java new file mode 100644 index 000000000..f1baf8ee9 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/domain/Artist.java @@ -0,0 +1,67 @@ +package com.festago.artist.domain; + +import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.util.ImageUrlHelper; +import com.festago.common.util.Validator; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Artist extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @Column(name = "profile_image_url") + private String profileImage; + + private String backgroundImageUrl; + + public Artist(Long id, String name, String profileImage, String backgroundImageUrl) { + validateName(name); + this.id = id; + this.name = name; + this.profileImage = ImageUrlHelper.getBlankStringIfBlank(profileImage); + this.backgroundImageUrl = ImageUrlHelper.getBlankStringIfBlank(backgroundImageUrl); + } + + private void validateName(String name) { + Validator.notBlank(name, "name"); + } + + public Artist(String name, String profileImage, String backgroundImageUrl) { + this(null, name, profileImage, backgroundImageUrl); + } + + public void update(String name, String profileImage, String backgroundImageUrl) { + validateName(name); + this.name = name; + this.profileImage = ImageUrlHelper.getBlankStringIfBlank(profileImage); + this.backgroundImageUrl = ImageUrlHelper.getBlankStringIfBlank(backgroundImageUrl); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getProfileImage() { + return profileImage; + } + + public String getBackgroundImageUrl() { + return backgroundImageUrl; + } +} diff --git a/backend/src/main/java/com/festago/artist/domain/ArtistsSerializer.java b/backend/src/main/java/com/festago/artist/domain/ArtistsSerializer.java new file mode 100644 index 000000000..33de86406 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/domain/ArtistsSerializer.java @@ -0,0 +1,9 @@ +package com.festago.artist.domain; + +import java.util.List; + +@FunctionalInterface +public interface ArtistsSerializer { + + String serialize(List artists); +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java new file mode 100644 index 000000000..59c11cabf --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistDetailV1Response.java @@ -0,0 +1,18 @@ +package com.festago.artist.dto; + +import com.querydsl.core.annotations.QueryProjection; +import java.util.List; + +public record ArtistDetailV1Response( + Long id, + String name, + String profileImageUrl, + String backgroundImageUrl, + List socialMedias +) { + + @QueryProjection + public ArtistDetailV1Response { + + } +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistFestivalV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistFestivalV1Response.java new file mode 100644 index 000000000..de8517ccf --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistFestivalV1Response.java @@ -0,0 +1,25 @@ +package com.festago.artist.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.festago.artist.infrastructure.JsonArtistsSerializer; +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +public record ArtistFestivalV1Response( + Long id, + String name, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl, + @JsonRawValue + @ArraySchema(schema = @Schema(implementation = JsonArtistsSerializer.ArtistQueryModel.class)) + String artists +) { + + @QueryProjection + public ArtistFestivalV1Response { + + } +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistMediaV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistMediaV1Response.java new file mode 100644 index 000000000..e5cbbb241 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistMediaV1Response.java @@ -0,0 +1,17 @@ +package com.festago.artist.dto; + +import com.festago.socialmedia.domain.SocialMediaType; +import com.querydsl.core.annotations.QueryProjection; + +public record ArtistMediaV1Response( + SocialMediaType type, + String name, + String logoUrl, + String url +) { + + @QueryProjection + public ArtistMediaV1Response { + + } +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistSearchStageCountV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistSearchStageCountV1Response.java new file mode 100644 index 000000000..f8b95fdcc --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistSearchStageCountV1Response.java @@ -0,0 +1,8 @@ +package com.festago.artist.dto; + +public record ArtistSearchStageCountV1Response( + Integer todayStage, + Integer plannedStage +) { + +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistSearchV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistSearchV1Response.java new file mode 100644 index 000000000..1fe05cb99 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistSearchV1Response.java @@ -0,0 +1,14 @@ +package com.festago.artist.dto; + +import com.querydsl.core.annotations.QueryProjection; + +public record ArtistSearchV1Response( + Long id, + String name, + String profileImageUrl +) { + + @QueryProjection + public ArtistSearchV1Response { + } +} diff --git a/backend/src/main/java/com/festago/artist/dto/ArtistTotalSearchV1Response.java b/backend/src/main/java/com/festago/artist/dto/ArtistTotalSearchV1Response.java new file mode 100644 index 000000000..882deaa39 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/ArtistTotalSearchV1Response.java @@ -0,0 +1,21 @@ +package com.festago.artist.dto; + +public record ArtistTotalSearchV1Response( + Long id, + String name, + String profileImageUrl, + Integer todayStage, + Integer plannedStage +) { + + public static ArtistTotalSearchV1Response of(ArtistSearchV1Response artistResponse, + ArtistSearchStageCountV1Response stageCount) { + return new ArtistTotalSearchV1Response( + artistResponse.id(), + artistResponse.name(), + artistResponse.profileImageUrl(), + stageCount.todayStage(), + stageCount.plannedStage() + ); + } +} diff --git a/backend/src/main/java/com/festago/artist/dto/command/ArtistCreateCommand.java b/backend/src/main/java/com/festago/artist/dto/command/ArtistCreateCommand.java new file mode 100644 index 000000000..16119e6de --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/command/ArtistCreateCommand.java @@ -0,0 +1,12 @@ +package com.festago.artist.dto.command; + +import lombok.Builder; + +@Builder +public record ArtistCreateCommand( + String name, + String profileImageUrl, + String backgroundImageUrl +) { + +} diff --git a/backend/src/main/java/com/festago/artist/dto/command/ArtistUpdateCommand.java b/backend/src/main/java/com/festago/artist/dto/command/ArtistUpdateCommand.java new file mode 100644 index 000000000..c14d165d4 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/command/ArtistUpdateCommand.java @@ -0,0 +1,12 @@ +package com.festago.artist.dto.command; + +import lombok.Builder; + +@Builder +public record ArtistUpdateCommand( + String name, + String profileImageUrl, + String backgroundImageUrl +) { + +} diff --git a/backend/src/main/java/com/festago/artist/dto/event/ArtistCreatedEvent.java b/backend/src/main/java/com/festago/artist/dto/event/ArtistCreatedEvent.java new file mode 100644 index 000000000..80abb6217 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/event/ArtistCreatedEvent.java @@ -0,0 +1,9 @@ +package com.festago.artist.dto.event; + +import com.festago.artist.domain.Artist; + +public record ArtistCreatedEvent( + Artist artist +) { + +} diff --git a/backend/src/main/java/com/festago/artist/dto/event/ArtistDeletedEvent.java b/backend/src/main/java/com/festago/artist/dto/event/ArtistDeletedEvent.java new file mode 100644 index 000000000..0d6e601fa --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/event/ArtistDeletedEvent.java @@ -0,0 +1,7 @@ +package com.festago.artist.dto.event; + +public record ArtistDeletedEvent( + Long artistId +) { + +} diff --git a/backend/src/main/java/com/festago/artist/dto/event/ArtistUpdatedEvent.java b/backend/src/main/java/com/festago/artist/dto/event/ArtistUpdatedEvent.java new file mode 100644 index 000000000..3596e2de5 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/dto/event/ArtistUpdatedEvent.java @@ -0,0 +1,9 @@ +package com.festago.artist.dto.event; + +import com.festago.artist.domain.Artist; + +public record ArtistUpdatedEvent( + Artist artist +) { + +} diff --git a/backend/src/main/java/com/festago/artist/infrastructure/JsonArtistsSerializer.java b/backend/src/main/java/com/festago/artist/infrastructure/JsonArtistsSerializer.java new file mode 100644 index 000000000..c17de96fb --- /dev/null +++ b/backend/src/main/java/com/festago/artist/infrastructure/JsonArtistsSerializer.java @@ -0,0 +1,51 @@ +package com.festago.artist.infrastructure; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.artist.domain.Artist; +import com.festago.artist.domain.ArtistsSerializer; +import com.festago.common.exception.UnexpectedException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JsonArtistsSerializer implements ArtistsSerializer { + + private final ObjectMapper objectMapper; + + @Override + public String serialize(List artists) { + try { + List artistQueryModels = artists.stream() + .map(ArtistQueryModel::from) + .toList(); + return objectMapper.writeValueAsString(artistQueryModels); + } catch (JsonProcessingException e) { + log.error(e.getMessage(), e); + throw new UnexpectedException("Artist 목록을 직렬화 하는 중에 문제가 발생했습니다."); + } + } + + /** + * 쿼리에서 사용되는 모델이므로, 필드를 추가해도 필드명은 변경되면 절대로 안 됨!!! + */ + public record ArtistQueryModel( + Long id, + String name, + String profileImageUrl, + String backgroundImageUrl + ) { + public static ArtistQueryModel from(Artist artist) { + return new ArtistQueryModel( + artist.getId(), + artist.getName(), + artist.getProfileImage(), + artist.getBackgroundImageUrl() + ); + } + } +} diff --git a/backend/src/main/java/com/festago/artist/presentation/v1/ArtistSearchV1Controller.java b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistSearchV1Controller.java new file mode 100644 index 000000000..edbff5779 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistSearchV1Controller.java @@ -0,0 +1,31 @@ +package com.festago.artist.presentation.v1; + +import com.festago.artist.application.ArtistTotalSearchV1Service; +import com.festago.artist.dto.ArtistTotalSearchV1Response; +import com.festago.common.util.Validator; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/search/artists") +@Tag(name = "아티스트 검색 요청 V1") +@RequiredArgsConstructor +public class ArtistSearchV1Controller { + + private final ArtistTotalSearchV1Service artistTotalSearchV1Service; + + @GetMapping + @Operation(description = "키워드로 아티스트 목록을 검색한다.", summary = "아티스트 검색") + public ResponseEntity> searchByKeyword(@RequestParam String keyword) { + Validator.notBlank(keyword, "keyword"); + List response = artistTotalSearchV1Service.findAllByKeyword(keyword); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/festago/artist/presentation/v1/ArtistV1Controller.java b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistV1Controller.java new file mode 100644 index 000000000..9fe1630d3 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/presentation/v1/ArtistV1Controller.java @@ -0,0 +1,66 @@ +package com.festago.artist.presentation.v1; + +import com.festago.artist.application.ArtistDetailV1QueryService; +import com.festago.artist.dto.ArtistDetailV1Response; +import com.festago.artist.dto.ArtistFestivalV1Response; +import com.festago.common.aop.ValidPageable; +import com.festago.common.dto.SliceResponse; +import com.festago.common.exception.ValidException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/artists") +@Tag(name = "아티스트 정보 요청 V1") +@RequiredArgsConstructor +public class ArtistV1Controller { + + private final ArtistDetailV1QueryService artistDetailV1QueryService; + + @GetMapping("/{artistId}") + @Operation(description = "아티스트의 정보를 조회한다.", summary = "아티스트 정보 조회") + public ResponseEntity findArtistDetail(@PathVariable Long artistId) { + return ResponseEntity.ok(artistDetailV1QueryService.findArtistDetail(artistId)); + } + + @GetMapping("/{artistId}/festivals") + @Operation(description = "아티스트가 참여한 축제 목록을 조회한다. isPast 값으로 종료 축제와 진행, 예정 축제를 구분 가능하다.", summary = "아티스트 참여 축제 목록 조회") + @ValidPageable(maxSize = 20) + public ResponseEntity> findArtistFestivals( + @PathVariable Long artistId, + @RequestParam(required = false) Long lastFestivalId, + @RequestParam(required = false) LocalDate lastStartDate, + @RequestParam(required = false, defaultValue = "false") boolean isPast, + @Parameter(description = "0 < size <= 20") @RequestParam(defaultValue = "10") int size + ) { + validate(lastFestivalId, lastStartDate); + Slice response = artistDetailV1QueryService.findArtistFestivals(artistId, + lastFestivalId, lastStartDate, isPast, PageRequest.ofSize(size)); + return ResponseEntity.ok(SliceResponse.from(response)); + } + + private void validate(Long lastFestivalId, LocalDate lastStartDate) { + validateCursor(lastFestivalId, lastStartDate); + } + + private void validateCursor(Long lastFestivalId, LocalDate lastStartDate) { + if (lastFestivalId == null && lastStartDate == null) { + return; + } + if (lastFestivalId != null && lastStartDate != null) { + return; + } + throw new ValidException("festivalId, lastStartDate 두 값 모두 요청하거나 요청하지 않아야합니다."); + } +} diff --git a/backend/src/main/java/com/festago/artist/repository/ArtistDetailV1QueryDslRepository.java b/backend/src/main/java/com/festago/artist/repository/ArtistDetailV1QueryDslRepository.java new file mode 100644 index 000000000..87235df24 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/repository/ArtistDetailV1QueryDslRepository.java @@ -0,0 +1,129 @@ +package com.festago.artist.repository; + +import static com.festago.artist.domain.QArtist.artist; +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.socialmedia.domain.QSocialMedia.socialMedia; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.stage.domain.QStageArtist.stageArtist; +import static com.querydsl.core.group.GroupBy.groupBy; +import static com.querydsl.core.group.GroupBy.list; + +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistDetailV1Response; +import com.festago.artist.dto.ArtistFestivalV1Response; +import com.festago.artist.dto.QArtistDetailV1Response; +import com.festago.artist.dto.QArtistFestivalV1Response; +import com.festago.artist.dto.QArtistMediaV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.socialmedia.domain.OwnerType; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistDetailV1QueryDslRepository extends QueryDslRepositorySupport { + + public ArtistDetailV1QueryDslRepository() { + super(Artist.class); + } + + public Optional findArtistDetail(Long artistId) { + List response = selectFrom(artist) + .leftJoin(socialMedia).on(socialMedia.ownerId.eq(artist.id).and(socialMedia.ownerType.eq(OwnerType.ARTIST))) + .where(artist.id.eq(artistId)) + .transform( + groupBy(artist.id).list( + new QArtistDetailV1Response( + artist.id, + artist.name, + artist.profileImage, + artist.backgroundImageUrl, + list( + new QArtistMediaV1Response( + socialMedia.mediaType, + socialMedia.name, + socialMedia.logoUrl, + socialMedia.url + ).skipNulls() + ) + ) + ) + ); + + if (response.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(response.get(0)); + } + + public Slice findArtistFestivals(ArtistFestivalSearchCondition condition) { + Pageable pageable = condition.pageable(); + Long artistId = condition.artistId(); + return applySlice( + pageable, + query -> query.select(new QArtistFestivalV1Response( + festival.id, + festival.name, + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + festival.posterImageUrl, + festivalQueryInfo.artistInfo + )) + .from(stageArtist) + .innerJoin(stage).on(stageArtist.artistId.eq(artistId).and(stage.id.eq(stageArtist.stageId))) + .innerJoin(festival).on(festival.id.eq(stage.festival.id)) + .leftJoin(festivalQueryInfo).on(festival.id.eq(festivalQueryInfo.festivalId)) + .where(getDynamicWhere(condition.isPast(), condition.lastStartDate(), condition.lastFestivalId(), + condition.currentTime())) + .orderBy(getDynamicOrderBy(condition.isPast())) + ); + } + + private BooleanExpression getDynamicWhere( + Boolean isPast, + LocalDate lastStartDate, + Long lastFestivalId, + LocalDate currentTime + ) { + if (hasCursor(lastStartDate, lastFestivalId)) { + return getCursorBasedWhere(isPast, lastStartDate, lastFestivalId); + } + return getDefaultWhere(isPast, currentTime); + } + + private boolean hasCursor(LocalDate lastStartDate, Long lastFestivalId) { + return lastStartDate != null && lastFestivalId != null; + } + + private BooleanExpression getCursorBasedWhere(boolean isPast, LocalDate lastStartDate, Long lastFestivalId) { + if (isPast) { + return festival.festivalDuration.startDate.lt(lastStartDate) + .or(festival.festivalDuration.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))); + } + return festival.festivalDuration.startDate.gt(lastStartDate) + .or(festival.festivalDuration.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))); + } + + private BooleanExpression getDefaultWhere(boolean isPast, LocalDate currentTime) { + if (isPast) { + return festival.festivalDuration.endDate.lt(currentTime); + } + return festival.festivalDuration.endDate.goe(currentTime); + } + + private OrderSpecifier[] getDynamicOrderBy(boolean isPast) { + if (isPast) { + return new OrderSpecifier[]{festival.festivalDuration.endDate.desc()}; + } + return new OrderSpecifier[]{festival.festivalDuration.startDate.asc(), festival.id.asc()}; + } +} diff --git a/backend/src/main/java/com/festago/artist/repository/ArtistFestivalSearchCondition.java b/backend/src/main/java/com/festago/artist/repository/ArtistFestivalSearchCondition.java new file mode 100644 index 000000000..7a27c464c --- /dev/null +++ b/backend/src/main/java/com/festago/artist/repository/ArtistFestivalSearchCondition.java @@ -0,0 +1,15 @@ +package com.festago.artist.repository; + +import java.time.LocalDate; +import org.springframework.data.domain.Pageable; + +public record ArtistFestivalSearchCondition( + Long artistId, + boolean isPast, + Long lastFestivalId, + LocalDate lastStartDate, + Pageable pageable, + LocalDate currentTime +) { + +} diff --git a/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java b/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java new file mode 100644 index 000000000..5c0d13687 --- /dev/null +++ b/backend/src/main/java/com/festago/artist/repository/ArtistRepository.java @@ -0,0 +1,31 @@ +package com.festago.artist.repository; + +import com.festago.artist.domain.Artist; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface ArtistRepository extends Repository { + + default Artist getOrThrow(Long artistId) { + return findById(artistId) + .orElseThrow(() -> new NotFoundException(ErrorCode.ARTIST_NOT_FOUND)); + } + + Artist save(Artist artist); + + void deleteById(Long artistId); + + Optional findById(Long id); + + long countByIdIn(List artistIds); + + List findByIdIn(Collection artistIds); + + boolean existsById(Long id); + + boolean existsByName(String name); +} diff --git a/backend/src/main/java/com/festago/artist/repository/ArtistSearchV1QueryDslRepository.java b/backend/src/main/java/com/festago/artist/repository/ArtistSearchV1QueryDslRepository.java new file mode 100644 index 000000000..323089d3e --- /dev/null +++ b/backend/src/main/java/com/festago/artist/repository/ArtistSearchV1QueryDslRepository.java @@ -0,0 +1,51 @@ +package com.festago.artist.repository; + +import static com.festago.artist.domain.QArtist.artist; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.stage.domain.QStageArtist.stageArtist; +import static com.querydsl.core.group.GroupBy.groupBy; + +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.dto.QArtistSearchV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.querydsl.core.group.GroupBy; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistSearchV1QueryDslRepository extends QueryDslRepositorySupport { + + public ArtistSearchV1QueryDslRepository() { + super(Artist.class); + } + + public List findAllByLike(String keyword) { + return select( + new QArtistSearchV1Response(artist.id, artist.name, artist.profileImage)) + .from(artist) + .where(artist.name.contains(keyword)) + .orderBy(artist.name.asc()) + .fetch(); + } + + public List findAllByEqual(String keyword) { + return select( + new QArtistSearchV1Response(artist.id, artist.name, artist.profileImage)) + .from(artist) + .where(artist.name.eq(keyword)) + .orderBy(artist.name.asc()) + .fetch(); + } + + public Map> findArtistsStageScheduleAfterDateTime(List artistIds, + LocalDateTime localDateTime) { + return selectFrom(stageArtist) + .innerJoin(stage).on(stage.id.eq(stageArtist.stageId)) + .where(stageArtist.artistId.in(artistIds) + .and(stage.startTime.goe(localDateTime))) + .transform(groupBy(stageArtist.artistId).as(GroupBy.list(stage.startTime))); + } +} diff --git a/backend/src/main/java/com/festago/auth/AdminAuthenticationArgumentResolver.java b/backend/src/main/java/com/festago/auth/AdminAuthenticationArgumentResolver.java new file mode 100644 index 000000000..c62ede6a2 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/AdminAuthenticationArgumentResolver.java @@ -0,0 +1,40 @@ +package com.festago.auth; + +import com.festago.auth.domain.authentication.AdminAuthentication; +import com.festago.auth.domain.authentication.Authentication; +import com.festago.common.exception.UnexpectedException; +import org.springframework.core.MethodParameter; +import org.springframework.util.Assert; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class AdminAuthenticationArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthenticateContext authenticateContext; + + public AdminAuthenticationArgumentResolver(AuthenticateContext authenticateContext) { + Assert.notNull(authenticateContext, "The authenticateContext must not be null"); + this.authenticateContext = authenticateContext; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(AdminAuthentication.class); + } + + @Override + public AdminAuthentication resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + Authentication authentication = authenticateContext.getAuthentication(); + if (authentication instanceof AdminAuthentication adminAuthentication) { + return adminAuthentication; + } + throw new UnexpectedException("인가된 권한이 인자의 권한과 맞지 않습니다."); + } +} diff --git a/backend/src/main/java/com/festago/auth/AnnotationAuthorizationInterceptor.java b/backend/src/main/java/com/festago/auth/AnnotationAuthorizationInterceptor.java new file mode 100644 index 000000000..02eaefc0d --- /dev/null +++ b/backend/src/main/java/com/festago/auth/AnnotationAuthorizationInterceptor.java @@ -0,0 +1,52 @@ +package com.festago.auth; + +import com.festago.auth.annotation.Authorization; +import com.festago.auth.application.HttpRequestTokenExtractor; +import com.festago.auth.domain.AuthenticationTokenExtractor; +import com.festago.auth.domain.authentication.Authentication; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.ForbiddenException; +import com.festago.common.exception.UnauthorizedException; +import com.festago.common.exception.UnexpectedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.util.Assert; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +public class AnnotationAuthorizationInterceptor implements HandlerInterceptor { + + private final HttpRequestTokenExtractor httpRequestTokenExtractor; + private final AuthenticationTokenExtractor authenticationTokenExtractor; + private final AuthenticateContext authenticateContext; + + public AnnotationAuthorizationInterceptor( + HttpRequestTokenExtractor httpRequestTokenExtractor, + AuthenticationTokenExtractor authenticationTokenExtractor, + AuthenticateContext authenticateContext) + { + Assert.notNull(httpRequestTokenExtractor, "The httpRequestTokenExtractor must not be null"); + Assert.notNull(authenticationTokenExtractor, "The authenticationTokenExtractor must not be null"); + Assert.notNull(authenticateContext, "The authenticateContext must not be null"); + this.httpRequestTokenExtractor = httpRequestTokenExtractor; + this.authenticationTokenExtractor = authenticationTokenExtractor; + this.authenticateContext = authenticateContext; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + HandlerMethod handlerMethod = (HandlerMethod) handler; + Authorization authorization = handlerMethod.getMethodAnnotation(Authorization.class); + if (authorization == null) { + throw new UnexpectedException("HandlerMethod에 Authorization 어노테이션이 없습니다."); + } + String token = httpRequestTokenExtractor.extract(request) + .orElseThrow(() -> new UnauthorizedException(ErrorCode.NEED_AUTH_TOKEN)); + Authentication authentication = authenticationTokenExtractor.extract(token); + if (authentication.getRole() != authorization.role()) { + throw new ForbiddenException(ErrorCode.NOT_ENOUGH_PERMISSION); + } + authenticateContext.setAuthentication(authentication); + return true; + } +} diff --git a/backend/src/main/java/com/festago/auth/AuthenticateContext.java b/backend/src/main/java/com/festago/auth/AuthenticateContext.java new file mode 100644 index 000000000..ba36ddb17 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/AuthenticateContext.java @@ -0,0 +1,30 @@ +package com.festago.auth; + +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.AnonymousAuthentication; +import com.festago.auth.domain.authentication.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +@Component +@RequestScope +public class AuthenticateContext { + + private Authentication authentication = AnonymousAuthentication.getInstance(); + + public void setAuthentication(Authentication authentication) { + this.authentication = authentication; + } + + public Long getId() { + return authentication.getId(); + } + + public Role getRole() { + return authentication.getRole(); + } + + public Authentication getAuthentication() { + return authentication; + } +} diff --git a/backend/src/main/java/com/festago/auth/FixedAuthorizationInterceptor.java b/backend/src/main/java/com/festago/auth/FixedAuthorizationInterceptor.java new file mode 100644 index 000000000..5689679fb --- /dev/null +++ b/backend/src/main/java/com/festago/auth/FixedAuthorizationInterceptor.java @@ -0,0 +1,49 @@ +package com.festago.auth; + +import com.festago.auth.application.HttpRequestTokenExtractor; +import com.festago.auth.domain.AuthenticationTokenExtractor; +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.Authentication; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.ForbiddenException; +import com.festago.common.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.util.Assert; +import org.springframework.web.servlet.HandlerInterceptor; + +public class FixedAuthorizationInterceptor implements HandlerInterceptor { + + private final HttpRequestTokenExtractor httpRequestTokenExtractor; + private final AuthenticationTokenExtractor authenticationTokenExtractor; + private final AuthenticateContext authenticateContext; + private final Role role; + + public FixedAuthorizationInterceptor( + HttpRequestTokenExtractor httpRequestTokenExtractor, + AuthenticationTokenExtractor authenticationTokenExtractor, + AuthenticateContext authenticateContext, + Role role + ) { + Assert.notNull(httpRequestTokenExtractor, "The httpRequestTokenExtractor must not be null"); + Assert.notNull(authenticationTokenExtractor, "The authenticationTokenExtractor must not be null"); + Assert.notNull(authenticateContext, "The authenticateContext must not be null"); + Assert.notNull(role, "The role must not be null"); + this.httpRequestTokenExtractor = httpRequestTokenExtractor; + this.authenticationTokenExtractor = authenticationTokenExtractor; + this.authenticateContext = authenticateContext; + this.role = role; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String token = httpRequestTokenExtractor.extract(request) + .orElseThrow(() -> new UnauthorizedException(ErrorCode.NEED_AUTH_TOKEN)); + Authentication authentication = authenticationTokenExtractor.extract(token); + if (authentication.getRole() != role) { + throw new ForbiddenException(ErrorCode.NOT_ENOUGH_PERMISSION); + } + authenticateContext.setAuthentication(authentication); + return true; + } +} diff --git a/backend/src/main/java/com/festago/auth/MemberAuthenticationArgumentResolver.java b/backend/src/main/java/com/festago/auth/MemberAuthenticationArgumentResolver.java new file mode 100644 index 000000000..12b219713 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/MemberAuthenticationArgumentResolver.java @@ -0,0 +1,37 @@ +package com.festago.auth; + +import com.festago.auth.domain.authentication.Authentication; +import com.festago.auth.domain.authentication.MemberAuthentication; +import com.festago.common.exception.UnexpectedException; +import org.springframework.core.MethodParameter; +import org.springframework.util.Assert; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class MemberAuthenticationArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthenticateContext authenticateContext; + + public MemberAuthenticationArgumentResolver(AuthenticateContext authenticateContext) { + Assert.notNull(authenticateContext, "The authenticateContext must not be null"); + this.authenticateContext = authenticateContext; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(MemberAuthentication.class); + } + + @Override + public MemberAuthentication resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory + ) { + Authentication authentication = authenticateContext.getAuthentication(); + if (authentication instanceof MemberAuthentication memberAuthentication) { + return memberAuthentication; + } + throw new UnexpectedException("인가된 권한이 인자의 권한과 맞지 않습니다."); + } +} diff --git a/backend/src/main/java/com/festago/presentation/auth/RoleArgumentResolver.java b/backend/src/main/java/com/festago/auth/RoleArgumentResolver.java similarity index 79% rename from backend/src/main/java/com/festago/presentation/auth/RoleArgumentResolver.java rename to backend/src/main/java/com/festago/auth/RoleArgumentResolver.java index c48d2c2f9..451288621 100644 --- a/backend/src/main/java/com/festago/presentation/auth/RoleArgumentResolver.java +++ b/backend/src/main/java/com/festago/auth/RoleArgumentResolver.java @@ -1,8 +1,7 @@ -package com.festago.presentation.auth; +package com.festago.auth; import com.festago.auth.domain.Role; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.ForbiddenException; +import com.festago.common.exception.UnexpectedException; import org.springframework.core.MethodParameter; import org.springframework.util.Assert; import org.springframework.web.bind.support.WebDataBinderFactory; @@ -10,6 +9,10 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; +/** + * @deprecated 기존 Long으로 식별자를 받는 Controller가 많기에, 해당 클래스 삭제하지 않고 유지 + */ +@Deprecated public class RoleArgumentResolver implements HandlerMethodArgumentResolver { private final Role role; @@ -30,9 +33,10 @@ public boolean supportsParameter(MethodParameter parameter) { @Override public Long resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + NativeWebRequest webRequest, WebDataBinderFactory binderFactory + ) { if (authenticateContext.getRole() != this.role) { - throw new ForbiddenException(ErrorCode.NOT_ENOUGH_PERMISSION); + throw new UnexpectedException("인가된 권한이 인자의 권한과 맞지 않습니다."); } return authenticateContext.getId(); } diff --git a/backend/src/main/java/com/festago/auth/annotation/Authorization.java b/backend/src/main/java/com/festago/auth/annotation/Authorization.java new file mode 100644 index 000000000..368831572 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/annotation/Authorization.java @@ -0,0 +1,14 @@ +package com.festago.auth.annotation; + +import com.festago.auth.domain.Role; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Authorization { + + Role role(); +} diff --git a/backend/src/main/java/com/festago/auth/annotation/Member.java b/backend/src/main/java/com/festago/auth/annotation/Member.java index 1bc35c87f..c1389f612 100644 --- a/backend/src/main/java/com/festago/auth/annotation/Member.java +++ b/backend/src/main/java/com/festago/auth/annotation/Member.java @@ -1,10 +1,12 @@ package com.festago.auth.annotation; +import io.swagger.v3.oas.annotations.Parameter; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +@Parameter(hidden = true) @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface Member { diff --git a/backend/src/main/java/com/festago/auth/annotation/MemberAuth.java b/backend/src/main/java/com/festago/auth/annotation/MemberAuth.java new file mode 100644 index 000000000..71184b3f2 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/annotation/MemberAuth.java @@ -0,0 +1,16 @@ +package com.festago.auth.annotation; + +import com.festago.auth.domain.Role; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@SecurityRequirement(name = "bearerAuth") +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Authorization(role = Role.MEMBER) +public @interface MemberAuth { + +} diff --git a/backend/src/main/java/com/festago/auth/application/AdminAuthService.java b/backend/src/main/java/com/festago/auth/application/AdminAuthService.java deleted file mode 100644 index ff21eea0e..000000000 --- a/backend/src/main/java/com/festago/auth/application/AdminAuthService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.festago.auth.application; - -import com.festago.admin.domain.Admin; -import com.festago.admin.repository.AdminRepository; -import com.festago.auth.domain.AuthPayload; -import com.festago.auth.domain.Role; -import com.festago.auth.dto.AdminLoginRequest; -import com.festago.auth.dto.AdminSignupRequest; -import com.festago.auth.dto.AdminSignupResponse; -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.ForbiddenException; -import com.festago.common.exception.UnauthorizedException; -import java.util.Objects; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional -@RequiredArgsConstructor -public class AdminAuthService { - - private static final String ROOT_ADMIN = "admin"; - - private final AuthProvider authProvider; - private final AdminRepository adminRepository; - - @Transactional(readOnly = true) - public String login(AdminLoginRequest request) { - Admin admin = findAdmin(request); - validatePassword(admin.getPassword(), request.password()); - AuthPayload authPayload = getAuthPayload(admin); - return authProvider.provide(authPayload); - } - - private Admin findAdmin(AdminLoginRequest request) { - return adminRepository.findByUsername(request.username()) - .orElseThrow(() -> new UnauthorizedException(ErrorCode.INCORRECT_PASSWORD_OR_ACCOUNT)); - } - - private void validatePassword(String password, String comparePassword) { - if (!Objects.equals(password, comparePassword)) { - throw new UnauthorizedException(ErrorCode.INCORRECT_PASSWORD_OR_ACCOUNT); - } - } - - private AuthPayload getAuthPayload(Admin admin) { - return new AuthPayload(admin.getId(), Role.ADMIN); - } - - public void initializeRootAdmin(String password) { - adminRepository.findByUsername(ROOT_ADMIN).ifPresentOrElse(admin -> { - throw new BadRequestException(ErrorCode.DUPLICATE_ACCOUNT_USERNAME); - }, () -> adminRepository.save(new Admin(ROOT_ADMIN, password))); - } - - public AdminSignupResponse signup(Long adminId, AdminSignupRequest request) { - validateRootAdmin(adminId); - String username = request.username(); - String password = request.password(); - validateExistsUsername(username); - Admin admin = adminRepository.save(new Admin(username, password)); - return new AdminSignupResponse(admin.getUsername()); - } - - private void validateExistsUsername(String username) { - if (adminRepository.existsByUsername(username)) { - throw new BadRequestException(ErrorCode.DUPLICATE_ACCOUNT_USERNAME); - } - } - - private void validateRootAdmin(Long adminId) { - adminRepository.findById(adminId) - .map(Admin::getUsername) - .filter(username -> Objects.equals(username, ROOT_ADMIN)) - .ifPresentOrElse(username -> { - }, () -> { - throw new ForbiddenException(ErrorCode.NOT_ENOUGH_PERMISSION); - }); - } -} diff --git a/backend/src/main/java/com/festago/auth/application/AuthExtractor.java b/backend/src/main/java/com/festago/auth/application/AuthExtractor.java deleted file mode 100644 index 326ae5121..000000000 --- a/backend/src/main/java/com/festago/auth/application/AuthExtractor.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.festago.auth.application; - -import com.festago.auth.domain.AuthPayload; - -public interface AuthExtractor { - - AuthPayload extract(String token); -} diff --git a/backend/src/main/java/com/festago/auth/application/AuthFacadeService.java b/backend/src/main/java/com/festago/auth/application/AuthFacadeService.java deleted file mode 100644 index 6642e956b..000000000 --- a/backend/src/main/java/com/festago/auth/application/AuthFacadeService.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.festago.auth.application; - -import com.festago.auth.domain.AuthPayload; -import com.festago.auth.domain.Role; -import com.festago.auth.domain.SocialType; -import com.festago.auth.domain.UserInfo; -import com.festago.auth.dto.LoginMemberDto; -import com.festago.auth.dto.LoginResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AuthFacadeService { - - private final AuthService authService; - private final OAuth2Clients oAuth2Clients; - private final AuthProvider authProvider; - - public LoginResponse login(SocialType socialType, String oAuthToken) { - UserInfo userInfo = getUserInfo(socialType, oAuthToken); - LoginMemberDto loginMember = authService.login(userInfo); - String accessToken = getAccessToken(loginMember.memberId()); - return LoginResponse.of(accessToken, loginMember); - } - - private String getAccessToken(Long memberId) { - return authProvider.provide(new AuthPayload(memberId, Role.MEMBER)); - } - - private UserInfo getUserInfo(SocialType socialType, String oAuthToken) { - OAuth2Client oAuth2Client = oAuth2Clients.getClient(socialType); - return oAuth2Client.getUserInfo(oAuthToken); - } - - public void deleteMember(Long memberId) { - authService.deleteMember(memberId); - } -} diff --git a/backend/src/main/java/com/festago/auth/application/AuthProvider.java b/backend/src/main/java/com/festago/auth/application/AuthProvider.java deleted file mode 100644 index f4a52c123..000000000 --- a/backend/src/main/java/com/festago/auth/application/AuthProvider.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.festago.auth.application; - -import com.festago.auth.domain.AuthPayload; - -public interface AuthProvider { - - String provide(AuthPayload authPayload); -} diff --git a/backend/src/main/java/com/festago/auth/application/AuthService.java b/backend/src/main/java/com/festago/auth/application/AuthService.java deleted file mode 100644 index 50c3ae317..000000000 --- a/backend/src/main/java/com/festago/auth/application/AuthService.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.festago.auth.application; - -import com.festago.auth.domain.UserInfo; -import com.festago.auth.dto.LoginMemberDto; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.NotFoundException; -import com.festago.member.domain.Member; -import com.festago.member.repository.MemberRepository; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional -@RequiredArgsConstructor -@Slf4j -public class AuthService { - - private final MemberRepository memberRepository; - - public LoginMemberDto login(UserInfo userInfo) { - Optional originMember = - memberRepository.findBySocialIdAndSocialType(userInfo.socialId(), userInfo.socialType()); - if (originMember.isPresent()) { - Member member = originMember.get(); - return LoginMemberDto.isExists(member); - } - Member newMember = signUp(userInfo); - return LoginMemberDto.isNew(newMember); - } - - private Member signUp(UserInfo userInfo) { - return memberRepository.save(userInfo.toMember()); - } - - public void deleteMember(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND)); - logDeleteMember(member); - memberRepository.delete(member); - } - - private void logDeleteMember(Member member) { - log.info("[DELETE MEMBER] memberId: {} / socialType: {} / socialId: {}", - member.getId(), member.getSocialType(), member.getSocialId()); - } -} diff --git a/backend/src/main/java/com/festago/auth/application/HttpRequestTokenExtractor.java b/backend/src/main/java/com/festago/auth/application/HttpRequestTokenExtractor.java new file mode 100644 index 000000000..5bd5d43a9 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/application/HttpRequestTokenExtractor.java @@ -0,0 +1,9 @@ +package com.festago.auth.application; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +public interface HttpRequestTokenExtractor { + + Optional extract(HttpServletRequest request); +} diff --git a/backend/src/main/java/com/festago/auth/application/OAuth2Client.java b/backend/src/main/java/com/festago/auth/application/OAuth2Client.java index 1e4e1d378..597cba378 100644 --- a/backend/src/main/java/com/festago/auth/application/OAuth2Client.java +++ b/backend/src/main/java/com/festago/auth/application/OAuth2Client.java @@ -5,6 +5,8 @@ public interface OAuth2Client { + String getAccessToken(String code); + UserInfo getUserInfo(String accessToken); SocialType getSocialType(); diff --git a/backend/src/main/java/com/festago/auth/application/OAuth2Clients.java b/backend/src/main/java/com/festago/auth/application/OAuth2Clients.java index 7805c57f0..8b7144d0e 100644 --- a/backend/src/main/java/com/festago/auth/application/OAuth2Clients.java +++ b/backend/src/main/java/com/festago/auth/application/OAuth2Clients.java @@ -3,12 +3,14 @@ import com.festago.auth.domain.SocialType; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.UnexpectedException; import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class OAuth2Clients { private final Map oAuth2ClientMap; @@ -43,7 +45,8 @@ public OAuth2ClientsBuilder addAll(List oAuth2Clients) { public OAuth2ClientsBuilder add(OAuth2Client oAuth2Client) { SocialType socialType = oAuth2Client.getSocialType(); if (oAuth2ClientMap.containsKey(socialType)) { - throw new InternalServerException(ErrorCode.DUPLICATE_SOCIAL_TYPE); + log.error("OAuth2 제공자는 중복될 수 없습니다."); + throw new UnexpectedException("중복된 OAuth2 제공자 입니다."); } oAuth2ClientMap.put(socialType, oAuth2Client); return this; diff --git a/backend/src/main/java/com/festago/auth/application/TokenExtractor.java b/backend/src/main/java/com/festago/auth/application/TokenExtractor.java deleted file mode 100644 index f9396bd9c..000000000 --- a/backend/src/main/java/com/festago/auth/application/TokenExtractor.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.festago.auth.application; - -import jakarta.servlet.http.HttpServletRequest; -import java.util.Optional; - -public interface TokenExtractor { - - Optional extract(HttpServletRequest request); -} diff --git a/backend/src/main/java/com/festago/auth/application/command/AdminAuthCommandService.java b/backend/src/main/java/com/festago/auth/application/command/AdminAuthCommandService.java new file mode 100644 index 000000000..66b67d4f5 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/application/command/AdminAuthCommandService.java @@ -0,0 +1,83 @@ +package com.festago.auth.application.command; + +import com.festago.admin.domain.Admin; +import com.festago.admin.repository.AdminRepository; +import com.festago.auth.domain.AuthType; +import com.festago.auth.domain.authentication.AdminAuthentication; +import com.festago.auth.dto.command.AdminLoginCommand; +import com.festago.auth.dto.command.AdminLoginResult; +import com.festago.auth.dto.command.AdminSignupCommand; +import com.festago.auth.infrastructure.AdminAuthenticationTokenProvider; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.ForbiddenException; +import com.festago.common.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AdminAuthCommandService { + + private final AdminAuthenticationTokenProvider adminAuthenticationTokenProvider; + private final AdminRepository adminRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public AdminLoginResult login(AdminLoginCommand command) { + Admin admin = findAdminWithAuthenticate(command); + AdminAuthentication adminAuthentication = new AdminAuthentication(admin.getId()); + String accessToken = adminAuthenticationTokenProvider.provide(adminAuthentication).token(); + return new AdminLoginResult( + admin.getUsername(), + getAuthType(admin), + accessToken + ); + } + + private Admin findAdminWithAuthenticate(AdminLoginCommand request) { + return adminRepository.findByUsername(request.username()) + .filter(admin -> passwordEncoder.matches(request.password(), admin.getPassword())) + .orElseThrow(() -> new UnauthorizedException(ErrorCode.INCORRECT_PASSWORD_OR_ACCOUNT)); + } + + private AuthType getAuthType(Admin admin) { + if (admin.isRootAdmin()) { + return AuthType.ROOT; + } + return AuthType.ADMIN; + } + + public void signup(Long adminId, AdminSignupCommand command) { + validateRootAdmin(adminId); + String username = command.username(); + String password = passwordEncoder.encode(command.password()); + validateExistsUsername(username); + adminRepository.save(new Admin(username, password)); + } + + private void validateRootAdmin(Long adminId) { + adminRepository.findById(adminId) + .filter(Admin::isRootAdmin) + .ifPresentOrElse(ignore -> { + }, () -> { + throw new ForbiddenException(ErrorCode.NOT_ENOUGH_PERMISSION); + }); + } + + private void validateExistsUsername(String username) { + if (adminRepository.existsByUsername(username)) { + throw new BadRequestException(ErrorCode.DUPLICATE_ACCOUNT_USERNAME); + } + } + + public void initializeRootAdmin(String password) { + adminRepository.findByUsername(Admin.ROOT_ADMIN_NAME) + .ifPresentOrElse(ignore -> { + throw new BadRequestException(ErrorCode.DUPLICATE_ACCOUNT_USERNAME); + }, () -> adminRepository.save(Admin.createRootAdmin(passwordEncoder.encode(password)))); + } +} diff --git a/backend/src/main/java/com/festago/auth/application/command/MemberAuthCommandService.java b/backend/src/main/java/com/festago/auth/application/command/MemberAuthCommandService.java new file mode 100644 index 000000000..80279b72e --- /dev/null +++ b/backend/src/main/java/com/festago/auth/application/command/MemberAuthCommandService.java @@ -0,0 +1,96 @@ +package com.festago.auth.application.command; + +import com.festago.auth.domain.RefreshToken; +import com.festago.auth.domain.UserInfo; +import com.festago.auth.domain.UserInfoMemberMapper; +import com.festago.auth.dto.event.MemberDeletedEvent; +import com.festago.auth.dto.v1.LoginResult; +import com.festago.auth.dto.v1.TokenRefreshResult; +import com.festago.auth.repository.RefreshTokenRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import com.festago.member.domain.Member; +import com.festago.member.repository.MemberRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class MemberAuthCommandService { + + private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final ApplicationEventPublisher eventPublisher; + private final UserInfoMemberMapper userInfoMemberMapper; + private final Clock clock; + + public LoginResult login(UserInfo userInfo) { + Member member = memberRepository.findBySocialIdAndSocialType(userInfo.socialId(), userInfo.socialType()) + .orElseGet(() -> signUp(userInfo)); + RefreshToken refreshToken = saveRefreshToken(member.getId()); + return new LoginResult( + member.getId(), + member.getNickname(), + member.getProfileImage(), + refreshToken.getId(), + refreshToken.getExpiredAt() + ); + } + + private Member signUp(UserInfo userInfo) { + Member member = userInfoMemberMapper.toMember(userInfo); + return memberRepository.save(member); + } + + private RefreshToken saveRefreshToken(Long memberId) { + return refreshTokenRepository.save(RefreshToken.of(memberId, LocalDateTime.now(clock))); + } + + public void logout(Long memberId, UUID refreshTokenId) { + refreshTokenRepository.findById(refreshTokenId) + .ifPresent(refreshToken -> { + if (refreshToken.isOwner(memberId)) { + refreshTokenRepository.deleteById(refreshTokenId); + } + }); + } + + public TokenRefreshResult refresh(UUID refreshTokenId) { + RefreshToken refreshToken = refreshTokenRepository.findById(refreshTokenId) + .orElseThrow(() -> { + log.warn("탈취 가능성이 있는 리프래쉬 토큰이 있습니다. token={}", refreshTokenId); + return new UnauthorizedException(ErrorCode.INVALID_REFRESH_TOKEN); + }); + if (refreshToken.isExpired(LocalDateTime.now(clock))) { + log.info("만료된 리프래쉬 토큰이 있습니다. memberId={}, token={}", refreshToken.getMemberId(), refreshTokenId); + throw new UnauthorizedException(ErrorCode.EXPIRED_REFRESH_TOKEN); + } + refreshTokenRepository.deleteById(refreshTokenId); + RefreshToken newRefreshToken = saveRefreshToken(refreshToken.getMemberId()); + return new TokenRefreshResult( + newRefreshToken.getMemberId(), + newRefreshToken.getId().toString(), + newRefreshToken.getExpiredAt() + ); + } + + public void deleteAccount(Long memberId) { + Member member = memberRepository.getOrThrow(memberId); + logDeleteMember(member); + memberRepository.delete(member); + eventPublisher.publishEvent(new MemberDeletedEvent(member)); + } + + private void logDeleteMember(Member member) { + log.info("[DELETE MEMBER] memberId: {} / socialType: {} / socialId: {}", + member.getId(), member.getSocialType(), member.getSocialId()); + } +} diff --git a/backend/src/main/java/com/festago/auth/application/command/MemberAuthFacadeService.java b/backend/src/main/java/com/festago/auth/application/command/MemberAuthFacadeService.java new file mode 100644 index 000000000..3980691f3 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/application/command/MemberAuthFacadeService.java @@ -0,0 +1,77 @@ +package com.festago.auth.application.command; + +import com.festago.auth.application.OAuth2Client; +import com.festago.auth.application.OAuth2Clients; +import com.festago.auth.domain.OpenIdClient; +import com.festago.auth.domain.OpenIdClients; +import com.festago.auth.domain.SocialType; +import com.festago.auth.domain.UserInfo; +import com.festago.auth.domain.authentication.MemberAuthentication; +import com.festago.auth.dto.v1.LoginResult; +import com.festago.auth.dto.v1.LoginV1Response; +import com.festago.auth.dto.v1.TokenRefreshResult; +import com.festago.auth.dto.v1.TokenRefreshV1Response; +import com.festago.auth.dto.v1.TokenResponse; +import com.festago.auth.infrastructure.MemberAuthenticationTokenProvider; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberAuthFacadeService { + + private final OAuth2Clients oAuth2Clients; + private final OpenIdClients openIdClients; + private final MemberAuthCommandService memberAuthCommandService; + private final MemberAuthenticationTokenProvider authTokenProvider; + + public LoginV1Response oAuth2Login(SocialType socialType, String code) { + OAuth2Client oAuth2Client = oAuth2Clients.getClient(socialType); + String oAuth2AccessToken = oAuth2Client.getAccessToken(code); + UserInfo userInfo = oAuth2Client.getUserInfo(oAuth2AccessToken); + return login(userInfo); + } + + private LoginV1Response login(UserInfo userInfo) { + LoginResult loginResult = memberAuthCommandService.login(userInfo); + + TokenResponse accessToken = authTokenProvider.provide(new MemberAuthentication(loginResult.memberId())); + return new LoginV1Response( + loginResult.nickname(), + loginResult.profileImageUrl(), + accessToken, + new TokenResponse( + loginResult.refreshToken().toString(), + loginResult.refreshTokenExpiredAt() + ) + ); + } + + public LoginV1Response openIdLogin(SocialType socialType, String idToken) { + OpenIdClient openIdClient = openIdClients.getClient(socialType); + UserInfo userInfo = openIdClient.getUserInfo(idToken); + return login(userInfo); + } + + public void logout(Long memberId, UUID refreshTokenId) { + memberAuthCommandService.logout(memberId, refreshTokenId); + } + + public TokenRefreshV1Response refresh(UUID refreshTokenId) { + TokenRefreshResult tokenRefreshResult = memberAuthCommandService.refresh(refreshTokenId); + Long memberId = tokenRefreshResult.memberId(); + TokenResponse accessToken = authTokenProvider.provide(new MemberAuthentication(memberId)); + return new TokenRefreshV1Response( + accessToken, + new TokenResponse( + tokenRefreshResult.token(), + tokenRefreshResult.expiredAt() + ) + ); + } + + public void deleteAccount(Long memberId) { + memberAuthCommandService.deleteAccount(memberId); + } +} diff --git a/backend/src/main/java/com/festago/auth/config/AuthConfig.java b/backend/src/main/java/com/festago/auth/config/AuthConfig.java index 5912d12b3..d7252d980 100644 --- a/backend/src/main/java/com/festago/auth/config/AuthConfig.java +++ b/backend/src/main/java/com/festago/auth/config/AuthConfig.java @@ -1,27 +1,18 @@ package com.festago.auth.config; -import com.festago.auth.application.AuthExtractor; -import com.festago.auth.application.AuthProvider; import com.festago.auth.application.OAuth2Client; import com.festago.auth.application.OAuth2Clients; -import com.festago.auth.infrastructure.JwtAuthExtractor; -import com.festago.auth.infrastructure.JwtAuthProvider; +import com.festago.auth.domain.OpenIdClient; +import com.festago.auth.domain.OpenIdClients; import java.util.List; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class AuthConfig { - private static final long EXPIRATION_MINUTES = 360; - - private final String secretKey; - - public AuthConfig(@Value("${festago.auth-secret-key}") String secretKey) { - this.secretKey = secretKey; - } - @Bean public OAuth2Clients oAuth2Clients(List oAuth2Clients) { return OAuth2Clients.builder() @@ -30,12 +21,14 @@ public OAuth2Clients oAuth2Clients(List oAuth2Clients) { } @Bean - public AuthProvider authProvider() { - return new JwtAuthProvider(secretKey, EXPIRATION_MINUTES); + public OpenIdClients openIdClients(List openIdClients) { + return OpenIdClients.builder() + .addAll(openIdClients) + .build(); } @Bean - public AuthExtractor authExtractor() { - return new JwtAuthExtractor(secretKey); + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } } diff --git a/backend/src/main/java/com/festago/auth/config/LoginConfig.java b/backend/src/main/java/com/festago/auth/config/LoginConfig.java index 5d06fa199..25729873a 100644 --- a/backend/src/main/java/com/festago/auth/config/LoginConfig.java +++ b/backend/src/main/java/com/festago/auth/config/LoginConfig.java @@ -1,16 +1,24 @@ package com.festago.auth.config; -import com.festago.auth.application.AuthExtractor; +import com.festago.auth.AdminAuthenticationArgumentResolver; +import com.festago.auth.AnnotationAuthorizationInterceptor; +import com.festago.auth.AuthenticateContext; +import com.festago.auth.FixedAuthorizationInterceptor; +import com.festago.auth.MemberAuthenticationArgumentResolver; +import com.festago.auth.RoleArgumentResolver; +import com.festago.auth.annotation.Authorization; +import com.festago.auth.domain.AuthenticationTokenExtractor; import com.festago.auth.domain.Role; -import com.festago.auth.infrastructure.CookieTokenExtractor; -import com.festago.auth.infrastructure.HeaderTokenExtractor; -import com.festago.presentation.auth.AuthInterceptor; -import com.festago.presentation.auth.AuthenticateContext; -import com.festago.presentation.auth.RoleArgumentResolver; +import com.festago.auth.infrastructure.CompositeHttpRequestTokenExtractor; +import com.festago.auth.infrastructure.CookieHttpRequestTokenExtractor; +import com.festago.auth.infrastructure.HeaderHttpRequestTokenExtractor; +import com.festago.common.interceptor.AnnotationDelegateInterceptor; +import com.festago.common.interceptor.HttpMethodDelegateInterceptor; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -19,42 +27,76 @@ @RequiredArgsConstructor public class LoginConfig implements WebMvcConfigurer { - private final AuthExtractor authExtractor; + private final AuthenticationTokenExtractor memberAuthenticationTokenExtractor; + private final AuthenticationTokenExtractor adminAuthenticationTokenExtractor; + private final AuthenticationTokenExtractor compositeAuthenticationTokenExtractor; private final AuthenticateContext authenticateContext; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(new RoleArgumentResolver(Role.MEMBER, authenticateContext)); resolvers.add(new RoleArgumentResolver(Role.ADMIN, authenticateContext)); + resolvers.add(new MemberAuthenticationArgumentResolver(authenticateContext)); + resolvers.add(new AdminAuthenticationArgumentResolver(authenticateContext)); } @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(adminAuthInterceptor()) - .addPathPatterns("/admin/**", "/js/admin/**") - .excludePathPatterns("/admin/login", "/admin/api/login", "/admin/api/initialize"); - registry.addInterceptor(memberAuthInterceptor()) - .addPathPatterns("/member-tickets/**", "/members/**", "/auth/**", "/students/**") + registry.addInterceptor(HttpMethodDelegateInterceptor.builder() + .allowMethod(HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE, HttpMethod.PUT, HttpMethod.PATCH) + .interceptor(adminFixedAuthorizationInterceptor()) + .build()) + .addPathPatterns("/admin/**") + .excludePathPatterns("/admin/api/v1/auth/login", "/admin/api/v1/auth/initialize"); + registry.addInterceptor(HttpMethodDelegateInterceptor.builder() + .allowMethod(HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE, HttpMethod.PUT, HttpMethod.PATCH) + .interceptor(memberFixedAuthorizationInterceptor()) + .build()) + .addPathPatterns("/member-tickets/**", "/members/**", "/auth/**", "/students/**", "/member-fcm/**") .excludePathPatterns("/auth/oauth2"); + registry.addInterceptor(AnnotationDelegateInterceptor.builder() + .annotation(Authorization.class) + .interceptor(annotationAuthorizationInterceptor()) + .build()) + .addPathPatterns("/api/**"); } @Bean - public AuthInterceptor adminAuthInterceptor() { - return AuthInterceptor.builder() - .authExtractor(authExtractor) - .tokenExtractor(new CookieTokenExtractor()) - .authenticateContext(authenticateContext) - .role(Role.ADMIN) - .build(); + public FixedAuthorizationInterceptor adminFixedAuthorizationInterceptor() { + return new FixedAuthorizationInterceptor( + compositeHttpRequestTokenExtractor(), + adminAuthenticationTokenExtractor, + authenticateContext, + Role.ADMIN + ); } @Bean - public AuthInterceptor memberAuthInterceptor() { - return AuthInterceptor.builder() - .authExtractor(authExtractor) - .tokenExtractor(new HeaderTokenExtractor()) - .authenticateContext(authenticateContext) - .role(Role.MEMBER) - .build(); + public CompositeHttpRequestTokenExtractor compositeHttpRequestTokenExtractor() { + return new CompositeHttpRequestTokenExtractor( + List.of( + new HeaderHttpRequestTokenExtractor(), + new CookieHttpRequestTokenExtractor() + ) + ); + } + + @Bean + public FixedAuthorizationInterceptor memberFixedAuthorizationInterceptor() { + return new FixedAuthorizationInterceptor( + compositeHttpRequestTokenExtractor(), + memberAuthenticationTokenExtractor, + authenticateContext, + Role.MEMBER + ); + } + + @Bean + public AnnotationAuthorizationInterceptor annotationAuthorizationInterceptor() { + return new AnnotationAuthorizationInterceptor( + compositeHttpRequestTokenExtractor(), + compositeAuthenticationTokenExtractor, + authenticateContext + ); } } diff --git a/backend/src/main/java/com/festago/auth/domain/AuthPayload.java b/backend/src/main/java/com/festago/auth/domain/AuthPayload.java deleted file mode 100644 index 1b174f74c..000000000 --- a/backend/src/main/java/com/festago/auth/domain/AuthPayload.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.festago.auth.domain; - -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; - -public class AuthPayload { - - private final Long memberId; - private final Role role; - - public AuthPayload(Long memberId, Role role) { - validate(role); - this.memberId = memberId; - this.role = role; - } - - private void validate(Role role) { - if (role == null) { - throw new InternalServerException(ErrorCode.INVALID_AUTH_TOKEN_PAYLOAD); - } - } - - public Long getMemberId() { - return memberId; - } - - public Role getRole() { - return role; - } -} diff --git a/backend/src/main/java/com/festago/auth/domain/AuthType.java b/backend/src/main/java/com/festago/auth/domain/AuthType.java new file mode 100644 index 000000000..98881c150 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/AuthType.java @@ -0,0 +1,7 @@ +package com.festago.auth.domain; + +public enum AuthType { + ROOT, + ADMIN, + ; +} diff --git a/backend/src/main/java/com/festago/auth/domain/AuthenticationTokenExtractor.java b/backend/src/main/java/com/festago/auth/domain/AuthenticationTokenExtractor.java new file mode 100644 index 000000000..7751ae5dc --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/AuthenticationTokenExtractor.java @@ -0,0 +1,12 @@ +package com.festago.auth.domain; + +import com.festago.auth.domain.authentication.Authentication; + +/** + * 문자열 형식의 토큰을 받아 Authentication을 반환하는 인터페이스
구현체에서 반환하는 Authentication는 반드시 null이 아니여야 한다.
null을 반환하는 대신 + * AnonymousAuthentication.getInstance() 반환할 것! + */ +public interface AuthenticationTokenExtractor { + + Authentication extract(String token); +} diff --git a/backend/src/main/java/com/festago/auth/domain/OpenIdClient.java b/backend/src/main/java/com/festago/auth/domain/OpenIdClient.java new file mode 100644 index 000000000..e35c02d56 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/OpenIdClient.java @@ -0,0 +1,8 @@ +package com.festago.auth.domain; + +public interface OpenIdClient { + + UserInfo getUserInfo(String idToken); + + SocialType getSocialType(); +} diff --git a/backend/src/main/java/com/festago/auth/domain/OpenIdClients.java b/backend/src/main/java/com/festago/auth/domain/OpenIdClients.java new file mode 100644 index 000000000..3d00f12e3 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/OpenIdClients.java @@ -0,0 +1,58 @@ +package com.festago.auth.domain; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnexpectedException; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class OpenIdClients { + + private final Map openIdClientMap; + + private OpenIdClients(Map openIdClientMap) { + this.openIdClientMap = openIdClientMap; + } + + public static OpenIdClientsBuilder builder() { + return new OpenIdClientsBuilder(); + } + + public OpenIdClient getClient(SocialType socialType) { + return Optional.ofNullable(openIdClientMap.get(socialType)) + .orElseThrow(() -> new BadRequestException(ErrorCode.OPEN_ID_NOT_SUPPORTED_SOCIAL_TYPE)); + } + + public static class OpenIdClientsBuilder { + + private final Map openIdClientMap = new EnumMap<>(SocialType.class); + + private OpenIdClientsBuilder() { + } + + public OpenIdClientsBuilder addAll(List openIdClients) { + for (OpenIdClient openIdClient : openIdClients) { + add(openIdClient); + } + return this; + } + + public OpenIdClientsBuilder add(OpenIdClient openIdClient) { + SocialType socialType = openIdClient.getSocialType(); + if (openIdClientMap.containsKey(socialType)) { + log.error("OpenID 제공자는 중복될 수 없습니다."); + throw new UnexpectedException("중복된 OpenID 제공자 입니다."); + } + openIdClientMap.put(socialType, openIdClient); + return this; + } + + public OpenIdClients build() { + return new OpenIdClients(openIdClientMap); + } + } +} diff --git a/backend/src/main/java/com/festago/auth/domain/OpenIdNonceValidator.java b/backend/src/main/java/com/festago/auth/domain/OpenIdNonceValidator.java new file mode 100644 index 000000000..53e283154 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/OpenIdNonceValidator.java @@ -0,0 +1,12 @@ +package com.festago.auth.domain; + +import java.util.Date; + +/** + * nonce을 기록하고, 기록된 nonce의 TTL은 expiredAt 이후로 삭제되게 한다.
nonce 값이 이미 기록된 nonce 값이면 주어지면 사용자의 토큰이 도난 당한 것으로 판단하여 예외를 + * 던져야한다.
+ */ +public interface OpenIdNonceValidator { + + void validate(String nonce, Date expiredAt); +} diff --git a/backend/src/main/java/com/festago/auth/domain/RefreshToken.java b/backend/src/main/java/com/festago/auth/domain/RefreshToken.java new file mode 100644 index 000000000..7c95dd58d --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/RefreshToken.java @@ -0,0 +1,62 @@ +package com.festago.auth.domain; + +import com.festago.common.domain.BaseTimeEntity; +import jakarta.annotation.Nonnull; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Persistable; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken extends BaseTimeEntity implements Persistable { + + private static final long EXPIRED_OFFSET_DAY = 7; + + @Id + private UUID id; + + private Long memberId; + + private LocalDateTime expiredAt; + + public RefreshToken(Long memberId, LocalDateTime expiredAt) { + this.id = UUID.randomUUID(); + this.memberId = memberId; + this.expiredAt = expiredAt; + } + + public static RefreshToken of(Long memberId, LocalDateTime now) { + return new RefreshToken(memberId, now.plusDays(EXPIRED_OFFSET_DAY)); + } + + public boolean isExpired(LocalDateTime now) { + return expiredAt.isBefore(now); + } + + public boolean isOwner(Long memberId) { + return Objects.equals(this.memberId, memberId); + } + + @Nonnull + public UUID getId() { + return id; + } + + @Override + public boolean isNew() { + return getCreatedAt() == null; + } + + public Long getMemberId() { + return memberId; + } + + public LocalDateTime getExpiredAt() { + return expiredAt; + } +} diff --git a/backend/src/main/java/com/festago/auth/domain/Role.java b/backend/src/main/java/com/festago/auth/domain/Role.java index 69936580b..d61a3b4a8 100644 --- a/backend/src/main/java/com/festago/auth/domain/Role.java +++ b/backend/src/main/java/com/festago/auth/domain/Role.java @@ -3,8 +3,7 @@ import com.festago.auth.annotation.Admin; import com.festago.auth.annotation.Anonymous; import com.festago.auth.annotation.Member; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.UnexpectedException; import java.lang.annotation.Annotation; public enum Role { @@ -23,7 +22,7 @@ public static Role from(String role) { try { return valueOf(role); } catch (NullPointerException | IllegalArgumentException e) { - throw new InternalServerException(ErrorCode.INVALID_ROLE_NAME); + throw new UnexpectedException("해당하는 Role이 없습니다."); } } diff --git a/backend/src/main/java/com/festago/auth/domain/SocialType.java b/backend/src/main/java/com/festago/auth/domain/SocialType.java index 4c19e94b6..a9fd21cc3 100644 --- a/backend/src/main/java/com/festago/auth/domain/SocialType.java +++ b/backend/src/main/java/com/festago/auth/domain/SocialType.java @@ -3,5 +3,6 @@ public enum SocialType { KAKAO, FESTAGO, + APPLE, ; } diff --git a/backend/src/main/java/com/festago/auth/domain/UserInfo.java b/backend/src/main/java/com/festago/auth/domain/UserInfo.java index 3df1d31be..232b81e4f 100644 --- a/backend/src/main/java/com/festago/auth/domain/UserInfo.java +++ b/backend/src/main/java/com/festago/auth/domain/UserInfo.java @@ -1,7 +1,8 @@ package com.festago.auth.domain; -import com.festago.member.domain.Member; +import lombok.Builder; +@Builder public record UserInfo( String socialId, SocialType socialType, @@ -9,12 +10,4 @@ public record UserInfo( String profileImage ) { - public Member toMember() { - return new Member( - socialId, - socialType, - nickname, - profileImage - ); - } } diff --git a/backend/src/main/java/com/festago/auth/domain/UserInfoMemberMapper.java b/backend/src/main/java/com/festago/auth/domain/UserInfoMemberMapper.java new file mode 100644 index 000000000..dbebb691d --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/UserInfoMemberMapper.java @@ -0,0 +1,24 @@ +package com.festago.auth.domain; + +import com.festago.member.domain.DefaultNicknamePolicy; +import com.festago.member.domain.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +@RequiredArgsConstructor +public class UserInfoMemberMapper { + + private final DefaultNicknamePolicy defaultNicknamePolicy; + + public Member toMember(UserInfo userInfo) { + String nickname = userInfo.nickname(); + return new Member( + userInfo.socialId(), + userInfo.socialType(), + StringUtils.hasText(nickname) ? nickname : defaultNicknamePolicy.generate(), + userInfo.profileImage() + ); + } +} diff --git a/backend/src/main/java/com/festago/auth/domain/authentication/AdminAuthentication.java b/backend/src/main/java/com/festago/auth/domain/authentication/AdminAuthentication.java new file mode 100644 index 000000000..f928a3e3c --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/authentication/AdminAuthentication.java @@ -0,0 +1,26 @@ +package com.festago.auth.domain.authentication; + +import com.festago.auth.domain.Role; +import com.festago.common.exception.UnexpectedException; + +public class AdminAuthentication implements Authentication { + + private final Long id; + + public AdminAuthentication(Long id) { + if (id == null) { + throw new UnexpectedException("id는 null이 될 수 없습니다."); + } + this.id = id; + } + + @Override + public Long getId() { + return id; + } + + @Override + public Role getRole() { + return Role.ADMIN; + } +} diff --git a/backend/src/main/java/com/festago/auth/domain/authentication/AnonymousAuthentication.java b/backend/src/main/java/com/festago/auth/domain/authentication/AnonymousAuthentication.java new file mode 100644 index 000000000..bbc9eecec --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/authentication/AnonymousAuthentication.java @@ -0,0 +1,25 @@ +package com.festago.auth.domain.authentication; + +import com.festago.auth.domain.Role; + +public class AnonymousAuthentication implements Authentication { + + private static final AnonymousAuthentication INSTANCE = new AnonymousAuthentication(); + + private AnonymousAuthentication() { + } + + public static Authentication getInstance() { + return INSTANCE; + } + + @Override + public Long getId() { + return null; + } + + @Override + public Role getRole() { + return Role.ANONYMOUS; + } +} diff --git a/backend/src/main/java/com/festago/auth/domain/authentication/Authentication.java b/backend/src/main/java/com/festago/auth/domain/authentication/Authentication.java new file mode 100644 index 000000000..b571257ef --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/authentication/Authentication.java @@ -0,0 +1,14 @@ +package com.festago.auth.domain.authentication; + +import com.festago.auth.domain.Role; + +/** + * 인증 정보를 담은 인터페이스
+ * 구현체는 반드시 getId(), getRole()에 null을 반환하지 않도록 해야한다. + */ +public interface Authentication { + + Long getId(); + + Role getRole(); +} diff --git a/backend/src/main/java/com/festago/auth/domain/authentication/MemberAuthentication.java b/backend/src/main/java/com/festago/auth/domain/authentication/MemberAuthentication.java new file mode 100644 index 000000000..fc27f667a --- /dev/null +++ b/backend/src/main/java/com/festago/auth/domain/authentication/MemberAuthentication.java @@ -0,0 +1,26 @@ +package com.festago.auth.domain.authentication; + +import com.festago.auth.domain.Role; +import com.festago.common.exception.UnexpectedException; + +public class MemberAuthentication implements Authentication { + + private final Long id; + + public MemberAuthentication(Long id) { + if (id == null) { + throw new UnexpectedException("id는 null이 될 수 없습니다."); + } + this.id = id; + } + + @Override + public Long getId() { + return id; + } + + @Override + public Role getRole() { + return Role.MEMBER; + } +} diff --git a/backend/src/main/java/com/festago/auth/dto/AdminLoginRequest.java b/backend/src/main/java/com/festago/auth/dto/AdminLoginRequest.java deleted file mode 100644 index 313977f5c..000000000 --- a/backend/src/main/java/com/festago/auth/dto/AdminLoginRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.festago.auth.dto; - -import jakarta.validation.constraints.NotBlank; - -public record AdminLoginRequest( - @NotBlank(message = "username은 공백일 수 없습니다.") - String username, - @NotBlank(message = "password는 공백일 수 없습니다.") - String password -) { - -} diff --git a/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Request.java b/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Request.java new file mode 100644 index 000000000..e5051f97e --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Request.java @@ -0,0 +1,19 @@ +package com.festago.auth.dto; + +import com.festago.auth.dto.command.AdminLoginCommand; +import jakarta.validation.constraints.NotBlank; + +public record AdminLoginV1Request( + @NotBlank + String username, + @NotBlank + String password +) { + + public AdminLoginCommand toCommand() { + return AdminLoginCommand.builder() + .username(username) + .password(password) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Response.java b/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Response.java new file mode 100644 index 000000000..42903f90b --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/AdminLoginV1Response.java @@ -0,0 +1,10 @@ +package com.festago.auth.dto; + +import com.festago.auth.domain.AuthType; + +public record AdminLoginV1Response( + String username, + AuthType authType +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/AdminSignupRequest.java b/backend/src/main/java/com/festago/auth/dto/AdminSignupRequest.java deleted file mode 100644 index 02c689d9f..000000000 --- a/backend/src/main/java/com/festago/auth/dto/AdminSignupRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.festago.auth.dto; - -import jakarta.validation.constraints.NotBlank; - -public record AdminSignupRequest( - @NotBlank(message = "username은 공백일 수 없습니다.") - String username, - @NotBlank(message = "password는 공백일 수 없습니다.") - String password -) { - -} diff --git a/backend/src/main/java/com/festago/auth/dto/AdminSignupResponse.java b/backend/src/main/java/com/festago/auth/dto/AdminSignupResponse.java deleted file mode 100644 index 99da63f2d..000000000 --- a/backend/src/main/java/com/festago/auth/dto/AdminSignupResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.festago.auth.dto; - -public record AdminSignupResponse( - String username -) { - -} diff --git a/backend/src/main/java/com/festago/auth/dto/AdminSignupV1Request.java b/backend/src/main/java/com/festago/auth/dto/AdminSignupV1Request.java new file mode 100644 index 000000000..bd630c314 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/AdminSignupV1Request.java @@ -0,0 +1,19 @@ +package com.festago.auth.dto; + +import com.festago.auth.dto.command.AdminSignupCommand; +import jakarta.validation.constraints.NotBlank; + +public record AdminSignupV1Request( + @NotBlank + String username, + @NotBlank + String password +) { + + public AdminSignupCommand toCommand() { + return AdminSignupCommand.builder() + .username(username) + .password(password) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/auth/dto/KakaoAccessTokenResponse.java b/backend/src/main/java/com/festago/auth/dto/KakaoAccessTokenResponse.java index c7f8c793d..3fbfe7585 100644 --- a/backend/src/main/java/com/festago/auth/dto/KakaoAccessTokenResponse.java +++ b/backend/src/main/java/com/festago/auth/dto/KakaoAccessTokenResponse.java @@ -1,13 +1,15 @@ package com.festago.auth.dto; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record KakaoAccessTokenResponse( - @JsonProperty("token_type") String tokenType, - @JsonProperty("access_token") String accessToken, - @JsonProperty("expires_in") Integer expiresIn, - @JsonProperty("refresh_token") String refreshToken, - @JsonProperty("refresh_token_expires_in") Integer refreshTokenExpiresIn + String tokenType, + String accessToken, + Integer expiresIn, + String refreshToken, + Integer refreshTokenExpiresIn ) { } diff --git a/backend/src/main/java/com/festago/auth/dto/KakaoUserInfo.java b/backend/src/main/java/com/festago/auth/dto/KakaoUserInfo.java index b10dd395a..e82122847 100644 --- a/backend/src/main/java/com/festago/auth/dto/KakaoUserInfo.java +++ b/backend/src/main/java/com/festago/auth/dto/KakaoUserInfo.java @@ -10,12 +10,12 @@ public record KakaoUserInfo( ) { public UserInfo toUserInfo() { - return new UserInfo( - id, - SocialType.KAKAO, - kakaoAccount.profile.nickname, - kakaoAccount.profile.thumbnailImageUrl - ); + return UserInfo.builder() + .socialId(id) + .socialType(SocialType.KAKAO) + .nickname(kakaoAccount.profile.nickname) + .profileImage(kakaoAccount.profile.thumbnailImageUrl) + .build(); } public record KakaoAccount( diff --git a/backend/src/main/java/com/festago/auth/dto/LoginMember.java b/backend/src/main/java/com/festago/auth/dto/LoginMember.java deleted file mode 100644 index 48a952fa6..000000000 --- a/backend/src/main/java/com/festago/auth/dto/LoginMember.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.festago.auth.dto; - -import io.swagger.v3.oas.annotations.Hidden; - -@Hidden -public record LoginMember( - Long memberId -) { - -} diff --git a/backend/src/main/java/com/festago/auth/dto/LoginMemberDto.java b/backend/src/main/java/com/festago/auth/dto/LoginMemberDto.java deleted file mode 100644 index 5a921bffb..000000000 --- a/backend/src/main/java/com/festago/auth/dto/LoginMemberDto.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.festago.auth.dto; - -import com.festago.member.domain.Member; - -public record LoginMemberDto( - boolean isNew, - Long memberId, - String nickname -) { - - public static LoginMemberDto isNew(Member member) { - return new LoginMemberDto(true, member.getId(), member.getNickname()); - } - - public static LoginMemberDto isExists(Member member) { - return new LoginMemberDto(false, member.getId(), member.getNickname()); - } -} diff --git a/backend/src/main/java/com/festago/auth/dto/LoginRequest.java b/backend/src/main/java/com/festago/auth/dto/LoginRequest.java deleted file mode 100644 index 5872e77e1..000000000 --- a/backend/src/main/java/com/festago/auth/dto/LoginRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.festago.auth.dto; - -import com.festago.auth.domain.SocialType; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -public record LoginRequest( - @NotNull(message = "socialType은 null 일 수 없습니다.") - SocialType socialType, - @NotBlank(message = "acessToken은 공백일 수 없습니다.") - String accessToken, - @NotBlank(message = "fcmToken은 공백일 수 없습니다.") - String fcmToken) { - -} diff --git a/backend/src/main/java/com/festago/auth/dto/LoginResponse.java b/backend/src/main/java/com/festago/auth/dto/LoginResponse.java deleted file mode 100644 index 7ae5a060a..000000000 --- a/backend/src/main/java/com/festago/auth/dto/LoginResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.festago.auth.dto; - -public record LoginResponse( - String accessToken, - String nickname, - boolean isNew -) { - - public static LoginResponse of(String accessToken, LoginMemberDto loginMember) { - return new LoginResponse(accessToken, loginMember.nickname(), loginMember.isNew()); - } -} diff --git a/backend/src/main/java/com/festago/auth/dto/command/AdminLoginCommand.java b/backend/src/main/java/com/festago/auth/dto/command/AdminLoginCommand.java new file mode 100644 index 000000000..6b628d8ef --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/command/AdminLoginCommand.java @@ -0,0 +1,11 @@ +package com.festago.auth.dto.command; + +import lombok.Builder; + +@Builder +public record AdminLoginCommand( + String username, + String password +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/command/AdminLoginResult.java b/backend/src/main/java/com/festago/auth/dto/command/AdminLoginResult.java new file mode 100644 index 000000000..1a14cad3f --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/command/AdminLoginResult.java @@ -0,0 +1,18 @@ +package com.festago.auth.dto.command; + +import com.festago.auth.domain.AuthType; + +// TODO Command에서 반환하는 객체의 이름을 어떻게 하면 좋을까 +// 버저닝을 사용하지 않고 AdminLoginResponse라고 한 뒤 버저닝 된 컨트롤러에서 AdminLoginV1Response 객체로 변환하여 사용? +// 혹은 지금과 같이 AdminLoginResult와 같이 Response라는 이름을 빼고 반환할지..? +// Controller에서 필요한 응답은 username과 authType임. +// 그리고 accessToken은 쿠키로 반환하기 때문에 다음과 같이 accessToken이 필드로 있게 되면 필요하지 않은 응답이 나감 +// 클라이언트에서 필요하지 않은 필드는 무시하지만, accessToken과 같은 보안에 관련된 값일때 필요하지 않은 값은 +// 필요가 없다면 보내지 않는게 좋지 않을까? +public record AdminLoginResult( + String username, + AuthType authType, + String accessToken +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/command/AdminSignupCommand.java b/backend/src/main/java/com/festago/auth/dto/command/AdminSignupCommand.java new file mode 100644 index 000000000..8d2295a3f --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/command/AdminSignupCommand.java @@ -0,0 +1,11 @@ +package com.festago.auth.dto.command; + +import lombok.Builder; + +@Builder +public record AdminSignupCommand( + String username, + String password +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/event/MemberCreatedEvent.java b/backend/src/main/java/com/festago/auth/dto/event/MemberCreatedEvent.java new file mode 100644 index 000000000..189b453bf --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/event/MemberCreatedEvent.java @@ -0,0 +1,9 @@ +package com.festago.auth.dto.event; + +import com.festago.member.domain.Member; + +public record MemberCreatedEvent( + Member member +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/event/MemberDeletedEvent.java b/backend/src/main/java/com/festago/auth/dto/event/MemberDeletedEvent.java new file mode 100644 index 000000000..a41ecdad8 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/event/MemberDeletedEvent.java @@ -0,0 +1,9 @@ +package com.festago.auth.dto.event; + +import com.festago.member.domain.Member; + +public record MemberDeletedEvent( + Member member +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/v1/LoginResult.java b/backend/src/main/java/com/festago/auth/dto/v1/LoginResult.java new file mode 100644 index 000000000..cd968edc4 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/v1/LoginResult.java @@ -0,0 +1,14 @@ +package com.festago.auth.dto.v1; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record LoginResult( + Long memberId, + String nickname, + String profileImageUrl, + UUID refreshToken, + LocalDateTime refreshTokenExpiredAt +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/v1/LoginV1Response.java b/backend/src/main/java/com/festago/auth/dto/v1/LoginV1Response.java new file mode 100644 index 000000000..2ad385c39 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/v1/LoginV1Response.java @@ -0,0 +1,10 @@ +package com.festago.auth.dto.v1; + +public record LoginV1Response( + String nickname, + String profileImageUrl, + TokenResponse accessToken, + TokenResponse refreshToken +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/v1/LogoutV1Request.java b/backend/src/main/java/com/festago/auth/dto/v1/LogoutV1Request.java new file mode 100644 index 000000000..7931aa243 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/v1/LogoutV1Request.java @@ -0,0 +1,10 @@ +package com.festago.auth.dto.v1; + +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.UUID; + +public record LogoutV1Request( + @NotNull @UUID String refreshToken +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/v1/OAuth2LoginV1Request.java b/backend/src/main/java/com/festago/auth/dto/v1/OAuth2LoginV1Request.java new file mode 100644 index 000000000..94e159250 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/v1/OAuth2LoginV1Request.java @@ -0,0 +1,12 @@ +package com.festago.auth.dto.v1; + +import com.festago.auth.domain.SocialType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record OAuth2LoginV1Request( + @NotNull SocialType socialType, + @NotBlank String code +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/v1/OpenIdLoginV1Request.java b/backend/src/main/java/com/festago/auth/dto/v1/OpenIdLoginV1Request.java new file mode 100644 index 000000000..ea8002621 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/v1/OpenIdLoginV1Request.java @@ -0,0 +1,12 @@ +package com.festago.auth.dto.v1; + +import com.festago.auth.domain.SocialType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record OpenIdLoginV1Request( + @NotNull SocialType socialType, + @NotBlank String idToken +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/v1/RefreshTokenV1Request.java b/backend/src/main/java/com/festago/auth/dto/v1/RefreshTokenV1Request.java new file mode 100644 index 000000000..936d1db4c --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/v1/RefreshTokenV1Request.java @@ -0,0 +1,10 @@ +package com.festago.auth.dto.v1; + +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.UUID; + +public record RefreshTokenV1Request( + @NotNull @UUID String refreshToken +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/v1/TokenRefreshResult.java b/backend/src/main/java/com/festago/auth/dto/v1/TokenRefreshResult.java new file mode 100644 index 000000000..631b5d0a3 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/v1/TokenRefreshResult.java @@ -0,0 +1,11 @@ +package com.festago.auth.dto.v1; + +import java.time.LocalDateTime; + +public record TokenRefreshResult( + Long memberId, + String token, + LocalDateTime expiredAt +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/v1/TokenRefreshV1Response.java b/backend/src/main/java/com/festago/auth/dto/v1/TokenRefreshV1Response.java new file mode 100644 index 000000000..9c94436eb --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/v1/TokenRefreshV1Response.java @@ -0,0 +1,8 @@ +package com.festago.auth.dto.v1; + +public record TokenRefreshV1Response( + TokenResponse accessToken, + TokenResponse refreshToken +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/v1/TokenResponse.java b/backend/src/main/java/com/festago/auth/dto/v1/TokenResponse.java new file mode 100644 index 000000000..daf897dde --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/v1/TokenResponse.java @@ -0,0 +1,10 @@ +package com.festago.auth.dto.v1; + +import java.time.LocalDateTime; + +public record TokenResponse( + String token, + LocalDateTime expiredAt +) { + +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/AdminAuthenticationClaimsExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/AdminAuthenticationClaimsExtractor.java new file mode 100644 index 000000000..7a246ae0c --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/AdminAuthenticationClaimsExtractor.java @@ -0,0 +1,23 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.AdminAuthentication; +import com.festago.auth.domain.authentication.AnonymousAuthentication; +import com.festago.auth.domain.authentication.Authentication; +import io.jsonwebtoken.Claims; +import org.springframework.stereotype.Component; + +@Component +public class AdminAuthenticationClaimsExtractor implements AuthenticationClaimsExtractor { + + private static final String ADMIN_ID_KEY = "adminId"; + + @Override + public Authentication extract(Claims claims) { + if (!claims.getAudience().contains(Role.ADMIN.name())) { + return AnonymousAuthentication.getInstance(); + } + Long adminId = claims.get(ADMIN_ID_KEY, Long.class); + return new AdminAuthentication(adminId); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/AdminAuthenticationTokenExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/AdminAuthenticationTokenExtractor.java new file mode 100644 index 000000000..f816f78ca --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/AdminAuthenticationTokenExtractor.java @@ -0,0 +1,21 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.domain.AuthenticationTokenExtractor; +import com.festago.auth.domain.authentication.Authentication; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminAuthenticationTokenExtractor implements AuthenticationTokenExtractor { + + private final JwtTokenParser jwtTokenParser; + private final AdminAuthenticationClaimsExtractor adminAuthenticationClaimsExtractor; + + @Override + public Authentication extract(String token) { + Claims claims = jwtTokenParser.getClaims(token); + return adminAuthenticationClaimsExtractor.extract(claims); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/AdminAuthenticationTokenProvider.java b/backend/src/main/java/com/festago/auth/infrastructure/AdminAuthenticationTokenProvider.java new file mode 100644 index 000000000..fe572fb13 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/AdminAuthenticationTokenProvider.java @@ -0,0 +1,26 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.AdminAuthentication; +import com.festago.auth.dto.v1.TokenResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminAuthenticationTokenProvider { + + private static final String ADMIN_ID_KEY = "adminId"; + private static final long EXPIRATION_MINUTES = 60L * 24L; + + private final TokenProviderTemplate tokenProviderTemplate; + + public TokenResponse provide(AdminAuthentication adminAuthentication) { + return tokenProviderTemplate.provide(EXPIRATION_MINUTES, + jwtBuilder -> jwtBuilder + .subject(adminAuthentication.getId().toString()) + .claim(ADMIN_ID_KEY, adminAuthentication.getId()) + .audience().add(Role.ADMIN.name()).and() + ); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/AuthenticationClaimsExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/AuthenticationClaimsExtractor.java new file mode 100644 index 000000000..7cceb81d0 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/AuthenticationClaimsExtractor.java @@ -0,0 +1,13 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.domain.authentication.Authentication; +import io.jsonwebtoken.Claims; + +/** + * AuthenticationTokenExtractor의 필드로 사용되기 위해 설계되었음
JWT Claims에서 값을 추출하여 Authentication을 반환하는 인터페이스
구현체에서 + * 반환하는 Authentication는 반드시 null이 아니여야 한다.
null을 반환하는 대신 AnonymousAuthentication.getInstance() 반환할 것! + */ +public interface AuthenticationClaimsExtractor { + + Authentication extract(Claims claims); +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/CompositeAuthenticationTokenExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/CompositeAuthenticationTokenExtractor.java new file mode 100644 index 000000000..96b7f02c1 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/CompositeAuthenticationTokenExtractor.java @@ -0,0 +1,30 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.domain.AuthenticationTokenExtractor; +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.AnonymousAuthentication; +import com.festago.auth.domain.authentication.Authentication; +import io.jsonwebtoken.Claims; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CompositeAuthenticationTokenExtractor implements AuthenticationTokenExtractor { + + private final JwtTokenParser jwtTokenParser; + private final List authenticationClaimsExtractors; + + @Override + public Authentication extract(String token) { + Claims claims = jwtTokenParser.getClaims(token); + for (AuthenticationClaimsExtractor claimsExtractor : authenticationClaimsExtractors) { + Authentication authentication = claimsExtractor.extract(claims); + if (authentication.getRole() != Role.ANONYMOUS) { + return authentication; + } + } + return AnonymousAuthentication.getInstance(); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/CompositeHttpRequestTokenExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/CompositeHttpRequestTokenExtractor.java new file mode 100644 index 000000000..ebb806424 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/CompositeHttpRequestTokenExtractor.java @@ -0,0 +1,24 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.application.HttpRequestTokenExtractor; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CompositeHttpRequestTokenExtractor implements HttpRequestTokenExtractor { + + private final List httpRequestTokenExtractors; + + @Override + public Optional extract(HttpServletRequest request) { + for (HttpRequestTokenExtractor httpRequestTokenExtractor : httpRequestTokenExtractors) { + Optional token = httpRequestTokenExtractor.extract(request); + if (token.isPresent()) { + return token; + } + } + return Optional.empty(); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/CookieHttpRequestTokenExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/CookieHttpRequestTokenExtractor.java new file mode 100644 index 000000000..b2504125c --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/CookieHttpRequestTokenExtractor.java @@ -0,0 +1,26 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.application.HttpRequestTokenExtractor; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Objects; +import java.util.Optional; + +public class CookieHttpRequestTokenExtractor implements HttpRequestTokenExtractor { + + private static final String TOKEN = "token"; + + @Override + public Optional extract(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + for (Cookie cookie : cookies) { + if (Objects.equals(TOKEN, cookie.getName())) { + return Optional.ofNullable(cookie.getValue()); + } + } + return Optional.empty(); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/CookieTokenExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/CookieTokenExtractor.java deleted file mode 100644 index e3d490374..000000000 --- a/backend/src/main/java/com/festago/auth/infrastructure/CookieTokenExtractor.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.festago.auth.infrastructure; - -import com.festago.auth.application.TokenExtractor; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Objects; -import java.util.Optional; - -public class CookieTokenExtractor implements TokenExtractor { - - private static final String TOKEN = "token"; - - @Override - public Optional extract(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies == null) { - return Optional.empty(); - } - for (Cookie cookie : cookies) { - if (Objects.equals(TOKEN, cookie.getName())) { - return Optional.ofNullable(cookie.getValue()); - } - } - return Optional.empty(); - } -} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/HeaderHttpRequestTokenExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/HeaderHttpRequestTokenExtractor.java new file mode 100644 index 000000000..74668dd22 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/HeaderHttpRequestTokenExtractor.java @@ -0,0 +1,33 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.application.HttpRequestTokenExtractor; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import org.springframework.http.HttpHeaders; + +public class HeaderHttpRequestTokenExtractor implements HttpRequestTokenExtractor { + + private static final String BEARER_TOKEN_PREFIX = "Bearer "; + + @Override + public Optional extract(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header == null) { + return Optional.empty(); + } + return Optional.of(extractToken(header)); + } + + private String extractToken(String header) { + validateHeader(header); + return header.substring(BEARER_TOKEN_PREFIX.length()).trim(); + } + + private void validateHeader(String header) { + if (!header.toLowerCase().startsWith(BEARER_TOKEN_PREFIX.toLowerCase())) { + throw new UnauthorizedException(ErrorCode.NOT_BEARER_TOKEN_TYPE); + } + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/HeaderTokenExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/HeaderTokenExtractor.java deleted file mode 100644 index f0eab8f78..000000000 --- a/backend/src/main/java/com/festago/auth/infrastructure/HeaderTokenExtractor.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.festago.auth.infrastructure; - -import com.festago.auth.application.TokenExtractor; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.UnauthorizedException; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Optional; -import org.springframework.http.HttpHeaders; - -public class HeaderTokenExtractor implements TokenExtractor { - - private static final String BEARER_TOKEN_PREFIX = "Bearer "; - - @Override - public Optional extract(HttpServletRequest request) { - String header = request.getHeader(HttpHeaders.AUTHORIZATION); - if (header == null) { - return Optional.empty(); - } - return Optional.of(extractToken(header)); - } - - private String extractToken(String header) { - validateHeader(header); - return header.substring(BEARER_TOKEN_PREFIX.length()).trim(); - } - - private void validateHeader(String header) { - if (!header.toLowerCase().startsWith(BEARER_TOKEN_PREFIX.toLowerCase())) { - throw new UnauthorizedException(ErrorCode.NOT_BEARER_TOKEN_TYPE); - } - } -} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/JwtAuthExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/JwtAuthExtractor.java deleted file mode 100644 index 5eef34745..000000000 --- a/backend/src/main/java/com/festago/auth/infrastructure/JwtAuthExtractor.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.festago.auth.infrastructure; - -import com.festago.auth.application.AuthExtractor; -import com.festago.auth.domain.AuthPayload; -import com.festago.auth.domain.Role; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.UnauthorizedException; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import java.nio.charset.StandardCharsets; -import javax.crypto.SecretKey; - -public class JwtAuthExtractor implements AuthExtractor { - - private static final String MEMBER_ID_KEY = "memberId"; - private static final String ROLE_ID_KEY = "role"; - - private final JwtParser jwtParser; - - public JwtAuthExtractor(String secretKey) { - SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); - this.jwtParser = Jwts.parserBuilder() - .setSigningKey(key) - .build(); - } - - @Override - public AuthPayload extract(String token) { - Claims claims = getClaims(token); - Long memberId = claims.get(MEMBER_ID_KEY, Long.class); - String role = claims.get(ROLE_ID_KEY, String.class); - return new AuthPayload(memberId, Role.from(role)); - } - - private Claims getClaims(String code) { - try { - return jwtParser.parseClaimsJws(code) - .getBody(); - } catch (ExpiredJwtException e) { - throw new UnauthorizedException(ErrorCode.EXPIRED_AUTH_TOKEN); - } catch (JwtException | IllegalArgumentException e) { - throw new UnauthorizedException(ErrorCode.INVALID_AUTH_TOKEN); - } - } -} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/JwtAuthProvider.java b/backend/src/main/java/com/festago/auth/infrastructure/JwtAuthProvider.java deleted file mode 100644 index 77ce6bda1..000000000 --- a/backend/src/main/java/com/festago/auth/infrastructure/JwtAuthProvider.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.festago.auth.infrastructure; - -import com.festago.auth.application.AuthProvider; -import com.festago.auth.domain.AuthPayload; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.security.Keys; -import java.nio.charset.StandardCharsets; -import java.util.Date; -import javax.crypto.SecretKey; - -public class JwtAuthProvider implements AuthProvider { - - private static final int SECOND_FACTOR = 60; - private static final int MILLISECOND_FACTOR = 1000; - private static final String MEMBER_ID_KEY = "memberId"; - private static final String ROLE_ID_KEY = "role"; - - private final SecretKey key; - private final long expirationMinutes; - - public JwtAuthProvider(String secretKey, long expirationMinutes) { - this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); - this.expirationMinutes = expirationMinutes; - } - - @Override - public String provide(AuthPayload authPayload) { - Date now = new Date(); - return Jwts.builder() - .claim(MEMBER_ID_KEY, authPayload.getMemberId()) - .claim(ROLE_ID_KEY, authPayload.getRole()) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + expirationMinutes * SECOND_FACTOR * MILLISECOND_FACTOR)) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } -} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/JwtTokenParser.java b/backend/src/main/java/com/festago/auth/infrastructure/JwtTokenParser.java new file mode 100644 index 000000000..f5751116a --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/JwtTokenParser.java @@ -0,0 +1,47 @@ +package com.festago.auth.infrastructure; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Date; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class JwtTokenParser { + + private final JwtParser jwtParser; + + public JwtTokenParser( + @Value("${festago.auth-secret-key}") String secretKey, + Clock clock + ) { + this.jwtParser = Jwts.parser() + .clock(() -> Date.from(clock.instant())) + .verifyWith(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8))) + .build(); + } + + public Claims getClaims(String token) { + try { + return jwtParser.parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + throw new UnauthorizedException(ErrorCode.EXPIRED_AUTH_TOKEN); + } catch (JwtException | IllegalArgumentException e) { + throw new UnauthorizedException(ErrorCode.INVALID_AUTH_TOKEN); + } catch (Exception e) { + log.error("JWT 토큰 파싱 중에 문제가 발생했습니다."); + throw e; + } + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/KakaoOAuth2Client.java b/backend/src/main/java/com/festago/auth/infrastructure/KakaoOAuth2Client.java deleted file mode 100644 index b89eda3c0..000000000 --- a/backend/src/main/java/com/festago/auth/infrastructure/KakaoOAuth2Client.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.festago.auth.infrastructure; - -import com.festago.auth.application.OAuth2Client; -import com.festago.auth.domain.SocialType; -import com.festago.auth.domain.UserInfo; -import org.springframework.stereotype.Component; - -@Component -public class KakaoOAuth2Client implements OAuth2Client { - - private final KakaoOAuth2UserInfoClient userInfoClient; - - public KakaoOAuth2Client(KakaoOAuth2UserInfoClient userInfoClient) { - this.userInfoClient = userInfoClient; - } - - @Override - public UserInfo getUserInfo(String accessToken) { - return userInfoClient.getUserInfo(accessToken); - } - - @Override - public SocialType getSocialType() { - return SocialType.KAKAO; - } -} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/MemberAuthenticationClaimsExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/MemberAuthenticationClaimsExtractor.java new file mode 100644 index 000000000..1c2653ca3 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/MemberAuthenticationClaimsExtractor.java @@ -0,0 +1,23 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.AnonymousAuthentication; +import com.festago.auth.domain.authentication.Authentication; +import com.festago.auth.domain.authentication.MemberAuthentication; +import io.jsonwebtoken.Claims; +import org.springframework.stereotype.Component; + +@Component +public class MemberAuthenticationClaimsExtractor implements AuthenticationClaimsExtractor { + + private static final String MEMBER_ID_KEY = "memberId"; + + @Override + public Authentication extract(Claims claims) { + if (!claims.getAudience().contains(Role.MEMBER.name())) { + return AnonymousAuthentication.getInstance(); + } + Long memberId = claims.get(MEMBER_ID_KEY, Long.class); + return new MemberAuthentication(memberId); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/MemberAuthenticationTokenExtractor.java b/backend/src/main/java/com/festago/auth/infrastructure/MemberAuthenticationTokenExtractor.java new file mode 100644 index 000000000..dc83680c1 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/MemberAuthenticationTokenExtractor.java @@ -0,0 +1,21 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.domain.AuthenticationTokenExtractor; +import com.festago.auth.domain.authentication.Authentication; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberAuthenticationTokenExtractor implements AuthenticationTokenExtractor { + + private final JwtTokenParser jwtTokenParser; + private final MemberAuthenticationClaimsExtractor memberAuthenticationClaimsExtractor; + + @Override + public Authentication extract(String token) { + Claims claims = jwtTokenParser.getClaims(token); + return memberAuthenticationClaimsExtractor.extract(claims); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/MemberAuthenticationTokenProvider.java b/backend/src/main/java/com/festago/auth/infrastructure/MemberAuthenticationTokenProvider.java new file mode 100644 index 000000000..830acc15e --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/MemberAuthenticationTokenProvider.java @@ -0,0 +1,26 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.MemberAuthentication; +import com.festago.auth.dto.v1.TokenResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberAuthenticationTokenProvider { + + private static final String MEMBER_ID_KEY = "memberId"; + private static final long EXPIRATION_MINUTES = 60L * 6L; + + private final TokenProviderTemplate tokenProviderTemplate; + + public TokenResponse provide(MemberAuthentication memberAuthentication) { + return tokenProviderTemplate.provide(EXPIRATION_MINUTES, + jwtBuilder -> jwtBuilder + .subject(memberAuthentication.getId().toString()) + .claim(MEMBER_ID_KEY, memberAuthentication.getId()) + .audience().add(Role.MEMBER.name()).and() + ); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/SocialTypeConverter.java b/backend/src/main/java/com/festago/auth/infrastructure/SocialTypeConverter.java new file mode 100644 index 000000000..0a6cb2fe7 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/SocialTypeConverter.java @@ -0,0 +1,14 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.domain.SocialType; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class SocialTypeConverter implements Converter { + + @Override + public SocialType convert(String socialType) { + return SocialType.valueOf(socialType.toUpperCase()); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/TokenProviderTemplate.java b/backend/src/main/java/com/festago/auth/infrastructure/TokenProviderTemplate.java new file mode 100644 index 000000000..a6824bdcc --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/TokenProviderTemplate.java @@ -0,0 +1,43 @@ +package com.festago.auth.infrastructure; + +import com.festago.auth.dto.v1.TokenResponse; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.function.UnaryOperator; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class TokenProviderTemplate { + + private final SecretKey secretKey; + private final Clock clock; + + public TokenProviderTemplate( + @Value("${festago.auth-secret-key}") String secretKey, + Clock clock + ) { + this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + this.clock = clock; + } + + public TokenResponse provide(long expirationMinutes, UnaryOperator template) { + Instant now = clock.instant(); + Instant expiredAt = now.plus(expirationMinutes, ChronoUnit.MINUTES); + JwtBuilder builder = Jwts.builder() + .expiration(Date.from(expiredAt)) + .issuedAt(Date.from(now)) + .signWith(secretKey); + template.apply(builder); + String accessToken = builder.compact(); + return new TokenResponse(accessToken, LocalDateTime.ofInstant(expiredAt, clock.getZone())); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/FestagoOAuth2Client.java b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/FestagoOAuth2Client.java similarity index 90% rename from backend/src/main/java/com/festago/auth/infrastructure/FestagoOAuth2Client.java rename to backend/src/main/java/com/festago/auth/infrastructure/oauth2/FestagoOAuth2Client.java index b46f74a3a..1470b330e 100644 --- a/backend/src/main/java/com/festago/auth/infrastructure/FestagoOAuth2Client.java +++ b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/FestagoOAuth2Client.java @@ -1,4 +1,4 @@ -package com.festago.auth.infrastructure; +package com.festago.auth.infrastructure.oauth2; import com.festago.auth.application.OAuth2Client; import com.festago.auth.domain.SocialType; @@ -25,6 +25,11 @@ public FestagoOAuth2Client() { userInfoMap.put("3", () -> new UserInfo("3", getSocialType(), "member3", PROFILE_IMAGE)); } + @Override + public String getAccessToken(String code) { + return code; + } + @Override public UserInfo getUserInfo(String accessToken) { return userInfoMap.getOrDefault(accessToken, () -> { diff --git a/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2AccessTokenClient.java b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2AccessTokenClient.java new file mode 100644 index 000000000..66c074b54 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2AccessTokenClient.java @@ -0,0 +1,59 @@ +package com.festago.auth.infrastructure.oauth2; + +import com.festago.auth.dto.KakaoAccessTokenResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class KakaoOAuth2AccessTokenClient { + + private static final String URL = "https://kauth.kakao.com/oauth/token"; + + private final RestTemplate restTemplate; + private final String grantType; + private final String clientId; + private final String redirectUri; + private final String clientSecret; + + public KakaoOAuth2AccessTokenClient( + @Value("${festago.oauth2.kakao.grant-type}") String grantType, + @Value("${festago.oauth2.kakao.rest-api-key}") String clientId, + @Value("${festago.oauth2.kakao.redirect-uri}") String redirectUri, + @Value("${festago.oauth2.kakao.client-secret}") String clientSecret, + RestTemplateBuilder restTemplateBuilder + ) { + this.grantType = grantType; + this.clientId = clientId; + this.redirectUri = redirectUri; + this.clientSecret = clientSecret; + this.restTemplate = restTemplateBuilder + .errorHandler(new KakaoOAuth2AccessTokenErrorHandler()) + .build(); + } + + public String getAccessToken(String code) { + HttpHeaders headers = getAccessTokenHeaders(code); + return requestAccessToken(headers); + } + + private HttpHeaders getAccessTokenHeaders(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("grant_type", grantType); + headers.set("client_id", clientId); + headers.set("redirect_uri", redirectUri); + headers.set("client_secret", clientSecret); + headers.set("code", code); + return headers; + } + + private String requestAccessToken(HttpHeaders headers) { + KakaoAccessTokenResponse response = restTemplate.postForEntity(URL, headers, + KakaoAccessTokenResponse.class).getBody(); + return response.accessToken(); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2AccessTokenErrorHandler.java b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2AccessTokenErrorHandler.java new file mode 100644 index 000000000..36144db02 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2AccessTokenErrorHandler.java @@ -0,0 +1,67 @@ +package com.festago.auth.infrastructure.oauth2; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import java.io.IOException; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.HttpStatusCodeException; + +@Slf4j +public class KakaoOAuth2AccessTokenErrorHandler extends DefaultResponseErrorHandler { + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + try { + super.handleError(response); + } catch (HttpStatusCodeException e) { + HttpStatusCode statusCode = response.getStatusCode(); + handle4xxError(statusCode, e); + handle5xxError(statusCode); + } + throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + private void handle4xxError(HttpStatusCode statusCode, HttpStatusCodeException e) { + if (statusCode.is4xxClientError()) { + KakaoOAuth2ErrorResponse errorResponse = e.getResponseBodyAs(KakaoOAuth2ErrorResponse.class); + handleErrorCode(errorResponse); + } + } + + private void handleErrorCode(KakaoOAuth2ErrorResponse errorResponse) { + handleKOE320Error(errorResponse); + log.warn("{}", errorResponse); + throw new InternalServerException(ErrorCode.OAUTH2_INVALID_REQUEST); + } + + private void handleKOE320Error(KakaoOAuth2ErrorResponse errorResponse) { + if (errorResponse != null && errorResponse.isErrorCodeKOE320()) { + throw new BadRequestException(ErrorCode.OAUTH2_INVALID_CODE); + } + } + + private void handle5xxError(HttpStatusCode statusCode) { + if (statusCode.is5xxServerError()) { + throw new InternalServerException(ErrorCode.OAUTH2_PROVIDER_NOT_RESPONSE); + } + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public record KakaoOAuth2ErrorResponse( + String error, + String errorDescription, + String errorCode + ) { + + public boolean isErrorCodeKOE320() { + return Objects.equals(errorCode, "KOE320"); + } + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2Client.java b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2Client.java new file mode 100644 index 000000000..36f64c87a --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2Client.java @@ -0,0 +1,30 @@ +package com.festago.auth.infrastructure.oauth2; + +import com.festago.auth.application.OAuth2Client; +import com.festago.auth.domain.SocialType; +import com.festago.auth.domain.UserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class KakaoOAuth2Client implements OAuth2Client { + + private final KakaoOAuth2AccessTokenClient accessTokenClient; + private final KakaoOAuth2UserInfoClient userInfoClient; + + @Override + public String getAccessToken(String code) { + return accessTokenClient.getAccessToken(code); + } + + @Override + public UserInfo getUserInfo(String accessToken) { + return userInfoClient.getUserInfo(accessToken); + } + + @Override + public SocialType getSocialType() { + return SocialType.KAKAO; + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoClient.java b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2UserInfoClient.java similarity index 96% rename from backend/src/main/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoClient.java rename to backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2UserInfoClient.java index 027f5ba6e..a725e5d92 100644 --- a/backend/src/main/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoClient.java +++ b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2UserInfoClient.java @@ -1,4 +1,4 @@ -package com.festago.auth.infrastructure; +package com.festago.auth.infrastructure.oauth2; import com.festago.auth.domain.UserInfo; import com.festago.auth.dto.KakaoUserInfo; diff --git a/backend/src/main/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoErrorHandler.java b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2UserInfoErrorHandler.java similarity index 81% rename from backend/src/main/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoErrorHandler.java rename to backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2UserInfoErrorHandler.java index 044f4d408..74e139bf6 100644 --- a/backend/src/main/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoErrorHandler.java +++ b/backend/src/main/java/com/festago/auth/infrastructure/oauth2/KakaoOAuth2UserInfoErrorHandler.java @@ -1,13 +1,15 @@ -package com.festago.auth.infrastructure; +package com.festago.auth.infrastructure.oauth2; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; import com.festago.common.exception.InternalServerException; import java.io.IOException; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatusCode; import org.springframework.http.client.ClientHttpResponse; import org.springframework.web.client.DefaultResponseErrorHandler; +@Slf4j public class KakaoOAuth2UserInfoErrorHandler extends DefaultResponseErrorHandler { @Override @@ -15,6 +17,7 @@ public void handleError(ClientHttpResponse response) throws IOException { HttpStatusCode statusCode = response.getStatusCode(); handle4xxError(statusCode); handle5xxError(statusCode); + log.error("카카오 OAuth2 요청 중 알 수 없는 문제가 발생했습니다."); throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); } @@ -26,6 +29,7 @@ private void handle4xxError(HttpStatusCode statusCode) { private void handle5xxError(HttpStatusCode statusCode) { if (statusCode.is5xxServerError()) { + log.warn("카카오 OAuth2 요청에 500 에러가 발생했습니다."); throw new InternalServerException(ErrorCode.OAUTH2_PROVIDER_NOT_RESPONSE); } } diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdClient.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdClient.java new file mode 100644 index 000000000..a64433ab3 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdClient.java @@ -0,0 +1,54 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.auth.domain.OpenIdClient; +import com.festago.auth.domain.OpenIdNonceValidator; +import com.festago.auth.domain.SocialType; +import com.festago.auth.domain.UserInfo; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import java.time.Clock; +import java.util.Date; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class AppleOpenIdClient implements OpenIdClient { + + private static final String ISSUER = "https://appleid.apple.com"; + private final OpenIdNonceValidator openIdNonceValidator; + private final OpenIdIdTokenParser idTokenParser; + private final String clientId; + + public AppleOpenIdClient( + @Value("${festago.oauth2.apple.client-id}") String appleClientId, + AppleOpenIdPublicKeyLocator appleOpenIdPublicKeyLocator, + OpenIdNonceValidator openIdNonceValidator, + Clock clock + ) { + this.clientId = appleClientId; + this.openIdNonceValidator = openIdNonceValidator; + this.idTokenParser = new OpenIdIdTokenParser(Jwts.parser() + .keyLocator(appleOpenIdPublicKeyLocator) + .requireAudience(clientId) + .requireIssuer(ISSUER) + .clock(() -> Date.from(clock.instant())) + .build()); + } + + @Override + public UserInfo getUserInfo(String idToken) { + Claims payload = idTokenParser.parse(idToken); + openIdNonceValidator.validate(payload.get("nonce", String.class), payload.getExpiration()); + return UserInfo.builder() + .socialType(SocialType.APPLE) + .socialId(payload.getSubject()) + .build(); + } + + @Override + public SocialType getSocialType() { + return SocialType.APPLE; + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdJwksClient.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdJwksClient.java new file mode 100644 index 000000000..3dc281437 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdJwksClient.java @@ -0,0 +1,44 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import io.jsonwebtoken.io.Parser; +import io.jsonwebtoken.security.JwkSet; +import io.jsonwebtoken.security.Jwks; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +@Component +@Slf4j +public class AppleOpenIdJwksClient { + + private final RestTemplate restTemplate; + private final Parser parser; + + public AppleOpenIdJwksClient( + RestTemplateBuilder restTemplateBuilder + ) { + this.restTemplate = restTemplateBuilder + .errorHandler(new AppleOpenIdJwksErrorHandler()) + .setConnectTimeout(Duration.ofSeconds(2)) + .setReadTimeout(Duration.ofSeconds(3)) + .build(); + this.parser = Jwks.setParser() + .build(); + } + + public JwkSet requestGetJwks() { + try { + String jsonKeys = restTemplate.getForObject("https://appleid.apple.com/auth/keys", String.class); + log.info("Apple JWKS 공개키 목록을 조회했습니다."); + return parser.parse(jsonKeys); + } catch (ResourceAccessException e) { + log.warn("Apple JWKS 서버가 응답하지 않습니다."); + throw new InternalServerException(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE); + } + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdJwksErrorHandler.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdJwksErrorHandler.java new file mode 100644 index 000000000..c0e2df2ba --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdJwksErrorHandler.java @@ -0,0 +1,24 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.DefaultResponseErrorHandler; + +@Slf4j +public class AppleOpenIdJwksErrorHandler extends DefaultResponseErrorHandler { + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + HttpStatusCode statusCode = response.getStatusCode(); + if (statusCode.isError()) { + log.warn("Apple JWKS 서버에서 {} 상태코드가 반환되었습니다.", statusCode.value()); + throw new InternalServerException(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE); + } + log.error("Apple JWKS 서버에서 알 수 없는 에러가 발생했습니다."); + throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdPublicKeyLocator.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdPublicKeyLocator.java new file mode 100644 index 000000000..34a427d9c --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/AppleOpenIdPublicKeyLocator.java @@ -0,0 +1,26 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Locator; +import java.security.Key; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AppleOpenIdPublicKeyLocator implements Locator { + + private final AppleOpenIdJwksClient appleOpenIdJwksClient; + private final CachedOpenIdKeyProvider cachedOpenIdKeyProvider; + + @Override + public Key locate(Header header) { + String kid = (String) header.get("kid"); + if (kid == null) { + throw new UnauthorizedException(ErrorCode.OPEN_ID_INVALID_TOKEN); + } + return cachedOpenIdKeyProvider.provide(kid, appleOpenIdJwksClient::requestGetJwks); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/CachedOpenIdKeyProvider.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/CachedOpenIdKeyProvider.java new file mode 100644 index 000000000..7a3619305 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/CachedOpenIdKeyProvider.java @@ -0,0 +1,65 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.common.exception.UnexpectedException; +import io.jsonwebtoken.security.JwkSet; +import jakarta.annotation.Nullable; +import java.security.Key; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@Scope("prototype") +public class CachedOpenIdKeyProvider { + + private final Map cache = new HashMap<>(); + private final ReentrantLock lock = new ReentrantLock(); + + /** + * OpenId Key를 캐싱하여 반환하는 클래스
OpenID Id Token 헤더의 kid 값을 key로 가지도록 구현
Id Token을 검증할 때, 매번 공개키 목록을 조회하면 + * 요청이 차단될 수 있으므로 캐싱하는 과정이 필요.
따라서 kid에 대한 Key를 찾을 수 없으면, fallback을 통해 캐시를 업데이트함
이때, 동시에 여러 요청이 들어오면 동시성 + * 문제가 발생할 수 있으므로 ReentrantLock을 사용하여 상호 배제 구현
데드락을 방지하기 위해 ReentrantLock.tryLock() 메서드를 사용하였음
또한 반드시 + * fallback에서 Timeout에 대한 예외 발생을 구현 해야함
존재하지 않는 kid로 계속 요청 시 fallback이 계속 호출되므로 공격 가능성이 있음.
+ * + * @param kid 캐시의 Key로 사용될 OpenId Id Token 헤더의 kid 값 + * @param fallback 캐시 미스 발생 시 캐시에 Key를 등록할 JwkSet을 반환하는 함수 + * @return 캐시 Hit의 경우 Key 반환, 캐시 Miss에서 fallback으로 반환된 JwkSet에 Key가 없으면 null 반환 + */ + @Nullable + public Key provide(String kid, Supplier fallback) { + Key key = cache.get(kid); + if (key != null) { + return key; + } + log.info("kid에 대한 OpenId Key를 찾지 못해 Key 목록 조회를 시도합니다. kid={}", kid); + try { + if (lock.tryLock(5, TimeUnit.SECONDS)) { + try { + key = cache.get(kid); + if (key != null) { + return key; + } + JwkSet jwkSet = fallback.get(); + jwkSet.forEach(jwk -> cache.put(jwk.getId(), jwk.toKey())); + key = cache.get(kid); + if (key == null) { + log.warn("OpenId kid에 대한 Key를 찾을 수 없습니다. kid={}", kid); + } + return key; + } finally { + lock.unlock(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("스레드가 인터럽트 되었습니다.", e); + } + throw new UnexpectedException("OpenId Key를 가져오는 중, 락 대기로 인해 Key를 획득하지 못했습니다. kid=" + kid); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/FestagoOpenIdClient.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/FestagoOpenIdClient.java new file mode 100644 index 000000000..836bb11dd --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/FestagoOpenIdClient.java @@ -0,0 +1,39 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.auth.domain.OpenIdClient; +import com.festago.auth.domain.SocialType; +import com.festago.auth.domain.UserInfo; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile("!prod") +public class FestagoOpenIdClient implements OpenIdClient { + + private static final String PROFILE_IMAGE = "https://placehold.co/150x150"; + + private final Map> userInfoMap = new HashMap<>(); + + public FestagoOpenIdClient() { + userInfoMap.put("1", () -> new UserInfo("1", getSocialType(), "member1", PROFILE_IMAGE)); + userInfoMap.put("2", () -> new UserInfo("2", getSocialType(), "member2", PROFILE_IMAGE)); + userInfoMap.put("3", () -> new UserInfo("3", getSocialType(), "member3", PROFILE_IMAGE)); + } + + @Override + public UserInfo getUserInfo(String idToken) { + return userInfoMap.getOrDefault(idToken, () -> { + throw new BadRequestException(ErrorCode.OPEN_ID_INVALID_TOKEN); + }).get(); + } + + @Override + public SocialType getSocialType() { + return SocialType.FESTAGO; + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdClient.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdClient.java new file mode 100644 index 000000000..09e1c4cd1 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdClient.java @@ -0,0 +1,70 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.auth.domain.OpenIdClient; +import com.festago.auth.domain.OpenIdNonceValidator; +import com.festago.auth.domain.SocialType; +import com.festago.auth.domain.UserInfo; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import java.time.Clock; +import java.util.Date; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class KakaoOpenIdClient implements OpenIdClient { + + private static final String ISSUER = "https://kauth.kakao.com"; + private final OpenIdNonceValidator openIdNonceValidator; + private final OpenIdIdTokenParser idTokenParser; + private final Set appKeys; + + public KakaoOpenIdClient( + @Value("${festago.oauth2.kakao.rest-api-key}") String restApiKey, + @Value("${festago.oauth2.kakao.native-app-key}") String nativeAppKey, + KakaoOpenIdPublicKeyLocator kakaoOpenIdPublicKeyLocator, + OpenIdNonceValidator openIdNonceValidator, + Clock clock + ) { + this.appKeys = Set.of(restApiKey, nativeAppKey); + this.openIdNonceValidator = openIdNonceValidator; + this.idTokenParser = new OpenIdIdTokenParser(Jwts.parser() + .keyLocator(kakaoOpenIdPublicKeyLocator) + .requireIssuer(ISSUER) + .clock(() -> Date.from(clock.instant())) + .build()); + } + + @Override + public UserInfo getUserInfo(String idToken) { + Claims payload = idTokenParser.parse(idToken); + openIdNonceValidator.validate(payload.get("nonce", String.class), payload.getExpiration()); + validateAudience(payload.getAudience()); + return UserInfo.builder() + .socialType(SocialType.KAKAO) + .socialId(payload.getSubject()) + .nickname(payload.get("nickname", String.class)) + .profileImage(payload.get("picture", String.class)) + .build(); + } + + private void validateAudience(Set audiences) { + for (String audience : audiences) { + if (appKeys.contains(audience)) { + return; + } + } + log.info("허용되지 않는 id 토큰의 audience 값이 요청되었습니다. audiences={}", audiences); + throw new UnauthorizedException(ErrorCode.OPEN_ID_INVALID_TOKEN); + } + + @Override + public SocialType getSocialType() { + return SocialType.KAKAO; + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdJwksClient.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdJwksClient.java new file mode 100644 index 000000000..43a86f38d --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdJwksClient.java @@ -0,0 +1,45 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import io.jsonwebtoken.io.Parser; +import io.jsonwebtoken.security.JwkSet; +import io.jsonwebtoken.security.Jwks; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Component +public class KakaoOpenIdJwksClient { + + private final RestTemplate restTemplate; + private final Parser parser; + + public KakaoOpenIdJwksClient( + RestTemplateBuilder restTemplateBuilder + ) { + this.restTemplate = restTemplateBuilder + .errorHandler(new KakaoOpenIdJwksErrorHandler()) + .setConnectTimeout(Duration.ofSeconds(2)) + .setReadTimeout(Duration.ofSeconds(3)) + .build(); + this.parser = Jwks.setParser() + .build(); + } + + // 너무 많은 요청이 오면 차단될 수 있음 + public JwkSet requestGetJwks() { + try { + String jsonKeys = restTemplate.getForObject("https://kauth.kakao.com/.well-known/jwks.json", String.class); + log.info("카카오 JWKS 공개키 목록을 조회했습니다."); + return parser.parse(jsonKeys); + } catch (ResourceAccessException e) { + log.warn("카카오 JWKS 서버가 응답하지 않습니다."); + throw new InternalServerException(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE); + } + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdJwksErrorHandler.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdJwksErrorHandler.java new file mode 100644 index 000000000..8d17df3b0 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdJwksErrorHandler.java @@ -0,0 +1,24 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.DefaultResponseErrorHandler; + +@Slf4j +public class KakaoOpenIdJwksErrorHandler extends DefaultResponseErrorHandler { + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + HttpStatusCode statusCode = response.getStatusCode(); + if (statusCode.isError()) { + log.warn("카카오 JWKS 서버에서 {} 상태코드가 반환되었습니다.", statusCode.value()); + throw new InternalServerException(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE); + } + log.error("카카오 JWKS 서버에서 알 수 없는 에러가 발생했습니다."); + throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdPublicKeyLocator.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdPublicKeyLocator.java new file mode 100644 index 000000000..4e474f768 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/KakaoOpenIdPublicKeyLocator.java @@ -0,0 +1,26 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Locator; +import java.security.Key; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class KakaoOpenIdPublicKeyLocator implements Locator { + + private final KakaoOpenIdJwksClient kakaoOpenIdJwksClient; + private final CachedOpenIdKeyProvider cachedOpenIdKeyProvider; + + @Override + public Key locate(Header header) { + String kid = (String) header.get("kid"); + if (kid == null) { + throw new UnauthorizedException(ErrorCode.OPEN_ID_INVALID_TOKEN); + } + return cachedOpenIdKeyProvider.provide(kid, kakaoOpenIdJwksClient::requestGetJwks); + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/NoopOpenIdNonceValidator.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/NoopOpenIdNonceValidator.java new file mode 100644 index 000000000..646bb4e5a --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/NoopOpenIdNonceValidator.java @@ -0,0 +1,17 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.auth.domain.OpenIdNonceValidator; +import java.util.Date; +import org.springframework.stereotype.Component; + +/** + * 2024-04-29 기준 nonce 값 검증 구현에 시간이 소요되므로, nonce 검증을 사용하지 않음.
nonce 검증 기능을 추가하면 해당 클래스 삭제할 것
+ */ +@Component +public class NoopOpenIdNonceValidator implements OpenIdNonceValidator { + + @Override + public void validate(String nonce, Date expiredAt) { + // noop + } +} diff --git a/backend/src/main/java/com/festago/auth/infrastructure/openid/OpenIdIdTokenParser.java b/backend/src/main/java/com/festago/auth/infrastructure/openid/OpenIdIdTokenParser.java new file mode 100644 index 000000000..24d85f0bf --- /dev/null +++ b/backend/src/main/java/com/festago/auth/infrastructure/openid/OpenIdIdTokenParser.java @@ -0,0 +1,28 @@ +package com.festago.auth.infrastructure.openid; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class OpenIdIdTokenParser { + + private final JwtParser jwtParser; + + public Claims parse(String idToken) { + try { + return jwtParser.parseSignedClaims(idToken).getPayload(); + } catch (JwtException | IllegalArgumentException e) { + log.info("OpenID Token 파싱에서 예외가 발생했습니다. message={}", e.getMessage()); + throw new UnauthorizedException(ErrorCode.OPEN_ID_INVALID_TOKEN); + } catch (Exception e) { + log.error("JWT 토큰 파싱 중에 문제가 발생했습니다.", e); + throw e; + } + } +} diff --git a/backend/src/main/java/com/festago/auth/presentation/v1/AdminAuthV1Controller.java b/backend/src/main/java/com/festago/auth/presentation/v1/AdminAuthV1Controller.java new file mode 100644 index 000000000..8be72a99b --- /dev/null +++ b/backend/src/main/java/com/festago/auth/presentation/v1/AdminAuthV1Controller.java @@ -0,0 +1,82 @@ +package com.festago.auth.presentation.v1; + +import com.festago.auth.application.command.AdminAuthCommandService; +import com.festago.auth.domain.authentication.AdminAuthentication; +import com.festago.auth.dto.AdminLoginV1Request; +import com.festago.auth.dto.AdminLoginV1Response; +import com.festago.auth.dto.AdminSignupV1Request; +import com.festago.auth.dto.RootAdminInitializeRequest; +import com.festago.auth.dto.command.AdminLoginResult; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/api/v1/auth") +@Hidden +@RequiredArgsConstructor +public class AdminAuthV1Controller { + + private final AdminAuthCommandService adminAuthCommandService; + + @PostMapping("/login") + public ResponseEntity login( + @RequestBody @Valid AdminLoginV1Request request + ) { + AdminLoginResult result = adminAuthCommandService.login(request.toCommand()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, createLoginCookie(result.accessToken())) + .body(new AdminLoginV1Response(result.username(), result.authType())); + } + + private String createLoginCookie(String token) { + return ResponseCookie.from("token", token) + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .build().toString(); + } + + @GetMapping("/logout") + public ResponseEntity logout() { + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, createLogoutCookie()) + .build(); + } + + private String createLogoutCookie() { + return ResponseCookie.from("token", "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(Duration.ZERO) + .build().toString(); + } + + @PostMapping("/signup") + public ResponseEntity signupAdminAccount( + @RequestBody @Valid AdminSignupV1Request request, + AdminAuthentication adminAuthentication + ) { + adminAuthCommandService.signup(adminAuthentication.getId(), request.toCommand()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/initialize") + public ResponseEntity initializeRootAdmin( + @RequestBody @Valid RootAdminInitializeRequest request + ) { + adminAuthCommandService.initializeRootAdmin(request.password()); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/festago/auth/presentation/v1/MemberAuthV1Controller.java b/backend/src/main/java/com/festago/auth/presentation/v1/MemberAuthV1Controller.java new file mode 100644 index 000000000..d389a3f98 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/presentation/v1/MemberAuthV1Controller.java @@ -0,0 +1,92 @@ +package com.festago.auth.presentation.v1; + +import com.festago.auth.annotation.MemberAuth; +import com.festago.auth.application.command.MemberAuthFacadeService; +import com.festago.auth.domain.SocialType; +import com.festago.auth.domain.authentication.MemberAuthentication; +import com.festago.auth.dto.v1.LoginV1Response; +import com.festago.auth.dto.v1.LogoutV1Request; +import com.festago.auth.dto.v1.OAuth2LoginV1Request; +import com.festago.auth.dto.v1.OpenIdLoginV1Request; +import com.festago.auth.dto.v1.RefreshTokenV1Request; +import com.festago.auth.dto.v1.TokenRefreshV1Response; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +@Tag(name = "회원 인증 요청 V1") +public class MemberAuthV1Controller { + + private final MemberAuthFacadeService memberAuthFacadeService; + + @PostMapping("/login/oauth2") + @Operation(description = "OAuth2 authorization_code를 받아 로그인/회원가입을 한다.", summary = "OAuth2 Authorization Code Grant 로그인") + public ResponseEntity oauth2Login( + @Valid @RequestBody OAuth2LoginV1Request request + ) { + return ResponseEntity.ok() + .body(memberAuthFacadeService.oAuth2Login(request.socialType(), request.code())); + } + + @Hidden // OAuth2 redirect-uri 스펙을 맞추기 위해 구현한 API + @GetMapping("/login/oauth2/{socialType}") + public ResponseEntity oauth2LoginWithPath( + @PathVariable SocialType socialType, + @RequestParam String code + ) { + return ResponseEntity.ok() + .body(memberAuthFacadeService.oAuth2Login(socialType, code)); + } + + @PostMapping("/login/open-id") + @Operation(description = "OpenID Id Token을 받아 로그인/회원가입을 한다.", summary = "OpenID Id Token 로그인") + public ResponseEntity openIdLogin( + @Valid @RequestBody OpenIdLoginV1Request request + ) { + return ResponseEntity.ok() + .body(memberAuthFacadeService.openIdLogin(request.socialType(), request.idToken())); + } + + @MemberAuth + @PostMapping("/logout") + @Operation(description = "로그인 된 사용자를 로그아웃 처리한다.", summary = "로그아웃") + public ResponseEntity logout( + MemberAuthentication memberAuthentication, + @RequestBody @Valid LogoutV1Request request + ) { + memberAuthFacadeService.logout(memberAuthentication.getId(), UUID.fromString(request.refreshToken())); + return ResponseEntity.ok().build(); + } + + @PostMapping("/refresh") + @Operation(description = "액세스/리프래쉬 토큰을 재발급한다.", summary = "액세스/리프래쉬 토큰 재발급") + public ResponseEntity refresh( + @Valid @RequestBody RefreshTokenV1Request request + ) { + return ResponseEntity.ok() + .body(memberAuthFacadeService.refresh(UUID.fromString(request.refreshToken()))); + } + + @MemberAuth + @DeleteMapping + @Operation(description = "사용자를 탈퇴 처리한다.", summary = "회원 탈퇴") + public ResponseEntity deleteAccount(MemberAuthentication memberAuthentication) { + memberAuthFacadeService.deleteAccount(memberAuthentication.getId()); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/festago/auth/repository/RefreshTokenRepository.java b/backend/src/main/java/com/festago/auth/repository/RefreshTokenRepository.java new file mode 100644 index 000000000..a2ee6e6c4 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,15 @@ +package com.festago.auth.repository; + +import com.festago.auth.domain.RefreshToken; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.repository.Repository; + +public interface RefreshTokenRepository extends Repository { + + RefreshToken save(RefreshToken refreshToken); + + Optional findById(UUID id); + + void deleteById(UUID id); +} diff --git a/backend/src/main/java/com/festago/bookmark/application/ArtistBookmarkV1QueryService.java b/backend/src/main/java/com/festago/bookmark/application/ArtistBookmarkV1QueryService.java new file mode 100644 index 000000000..5b6e01783 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/ArtistBookmarkV1QueryService.java @@ -0,0 +1,20 @@ +package com.festago.bookmark.application; + +import com.festago.bookmark.dto.v1.ArtistBookmarkV1Response; +import com.festago.bookmark.repository.v1.ArtistBookmarkV1QueryDslRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ArtistBookmarkV1QueryService { + + private final ArtistBookmarkV1QueryDslRepository artistBookmarkV1QueryDslRepository; + + public List findArtistBookmarksByMemberId(Long memberId){ + return artistBookmarkV1QueryDslRepository.findByMemberId(memberId); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/FestivalBookmarkV1QueryService.java b/backend/src/main/java/com/festago/bookmark/application/FestivalBookmarkV1QueryService.java new file mode 100644 index 000000000..0a738724c --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/FestivalBookmarkV1QueryService.java @@ -0,0 +1,33 @@ +package com.festago.bookmark.application; + +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.bookmark.repository.FestivalBookmarkOrder; +import com.festago.bookmark.repository.v1.FestivalBookmarkV1QueryDslRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FestivalBookmarkV1QueryService { + + private final FestivalBookmarkV1QueryDslRepository festivalBookmarkV1QueryDslRepository; + + public List findBookmarkedFestivalIds(Long memberId) { + return festivalBookmarkV1QueryDslRepository.findBookmarkedFestivalIds(memberId); + } + + public List findBookmarkedFestivals( + Long memberId, + List festivalIds, + FestivalBookmarkOrder festivalBookmarkOrder + ) { + return festivalBookmarkV1QueryDslRepository.findBookmarkedFestivals( + memberId, + festivalIds, + festivalBookmarkOrder + ); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/SchoolBookmarkV1QueryService.java b/backend/src/main/java/com/festago/bookmark/application/SchoolBookmarkV1QueryService.java new file mode 100644 index 000000000..cc0a58c40 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/SchoolBookmarkV1QueryService.java @@ -0,0 +1,21 @@ +package com.festago.bookmark.application; + +import com.festago.bookmark.dto.v1.SchoolBookmarkV1Response; +import com.festago.bookmark.repository.v1.SchoolBookmarkV1QuerydslRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SchoolBookmarkV1QueryService { + + private final SchoolBookmarkV1QuerydslRepository schoolBookmarkV1QuerydslRepository; + + public List findAllByMemberId(Long memberId) { + return schoolBookmarkV1QuerydslRepository.findAllByMemberId(memberId); + } +} + diff --git a/backend/src/main/java/com/festago/bookmark/application/command/ArtistBookmarkCommandService.java b/backend/src/main/java/com/festago/bookmark/application/command/ArtistBookmarkCommandService.java new file mode 100644 index 000000000..e28d10995 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/command/ArtistBookmarkCommandService.java @@ -0,0 +1,61 @@ +package com.festago.bookmark.application.command; + +import com.festago.artist.repository.ArtistRepository; +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ArtistBookmarkCommandService { + + private static final long MAX_ARTIST_BOOKMARK_COUNT = 12L; + + private final BookmarkRepository bookmarkRepository; + private final ArtistRepository artistRepository; + + public void save(Long artistId, Long memberId) { + validate(artistId, memberId); + if (isExistsBookmark(artistId, memberId)) { + return; + } + bookmarkRepository.save(new Bookmark(BookmarkType.ARTIST, artistId, memberId)); + } + + private void validate(Long artistId, Long memberId) { + validateExistArtist(artistId); + validateMaxBookmark(memberId); + } + + private void validateExistArtist(Long artistId) { + if (!artistRepository.existsById(artistId)) { + throw new NotFoundException(ErrorCode.ARTIST_NOT_FOUND); + } + } + + private void validateMaxBookmark(Long memberId) { + long bookmarkCount = bookmarkRepository.countByMemberIdAndBookmarkType(memberId, BookmarkType.ARTIST); + if (bookmarkCount >= MAX_ARTIST_BOOKMARK_COUNT) { + throw new BadRequestException(ErrorCode.BOOKMARK_LIMIT_EXCEEDED); + } + } + + private boolean isExistsBookmark(Long artistId, Long memberId) { + return bookmarkRepository.existsByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType.ARTIST, + memberId, + artistId + ); + } + + public void delete(Long artistId, Long memberId) { + bookmarkRepository.deleteByBookmarkTypeAndMemberIdAndResourceId(BookmarkType.ARTIST, memberId, artistId); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/command/BookmarkFacadeService.java b/backend/src/main/java/com/festago/bookmark/application/command/BookmarkFacadeService.java new file mode 100644 index 000000000..056331b9d --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/command/BookmarkFacadeService.java @@ -0,0 +1,38 @@ +package com.festago.bookmark.application.command; + +import com.festago.bookmark.domain.BookmarkType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BookmarkFacadeService { + + private final SchoolBookmarkCommandService schoolBookmarkCommandService; + private final ArtistBookmarkCommandService artistBookmarkCommandService; + private final FestivalBookmarkCommandService festivalBookmarkCommandService; + + public void save( + BookmarkType bookmarkType, + Long resourceId, + Long memberId + ) { + switch (bookmarkType) { + case SCHOOL -> schoolBookmarkCommandService.save(resourceId, memberId); + case ARTIST -> artistBookmarkCommandService.save(resourceId, memberId); + case FESTIVAL -> festivalBookmarkCommandService.save(resourceId, memberId); + } + } + + public void delete( + BookmarkType bookmarkType, + Long resourceId, + Long memberId + ) { + switch (bookmarkType) { + case SCHOOL -> schoolBookmarkCommandService.delete(resourceId, memberId); + case ARTIST -> artistBookmarkCommandService.delete(resourceId, memberId); + case FESTIVAL -> festivalBookmarkCommandService.delete(resourceId, memberId); + } + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/command/FestivalBookmarkCommandService.java b/backend/src/main/java/com/festago/bookmark/application/command/FestivalBookmarkCommandService.java new file mode 100644 index 000000000..0ad04ff5c --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/command/FestivalBookmarkCommandService.java @@ -0,0 +1,57 @@ +package com.festago.bookmark.application.command; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.repository.FestivalRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FestivalBookmarkCommandService { + + private static final long MAX_FESTIVAL_BOOKMARK_COUNT = 12L; + + private final BookmarkRepository bookmarkRepository; + private final FestivalRepository festivalRepository; + + public void save(Long festivalId, Long memberId) { + validate(festivalId, memberId); + if (isExistsBookmark(festivalId, memberId)) { + return; + } + bookmarkRepository.save(new Bookmark(BookmarkType.FESTIVAL, festivalId, memberId)); + } + + private void validate(Long festivalId, Long memberId) { + if (!festivalRepository.existsById(festivalId)) { + throw new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND); + } + long festivalBookmarkCount = bookmarkRepository.countByMemberIdAndBookmarkType(memberId, BookmarkType.FESTIVAL); + if (festivalBookmarkCount >= MAX_FESTIVAL_BOOKMARK_COUNT) { + throw new BadRequestException(ErrorCode.BOOKMARK_LIMIT_EXCEEDED); + } + } + + private boolean isExistsBookmark(Long festivalId, Long memberId) { + return bookmarkRepository.existsByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType.FESTIVAL, + memberId, + festivalId + ); + } + + public void delete(Long festivalId, Long memberId) { + bookmarkRepository.deleteByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType.FESTIVAL, + memberId, + festivalId + ); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/application/command/SchoolBookmarkCommandService.java b/backend/src/main/java/com/festago/bookmark/application/command/SchoolBookmarkCommandService.java new file mode 100644 index 000000000..6bdae2079 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/application/command/SchoolBookmarkCommandService.java @@ -0,0 +1,54 @@ +package com.festago.bookmark.application.command; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.school.repository.SchoolRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class SchoolBookmarkCommandService { + + private static final long MAX_SCHOOL_BOOKMARK_COUNT = 12L; + + private final BookmarkRepository bookmarkRepository; + private final SchoolRepository schoolRepository; + + public void save(Long schoolId, Long memberId) { + validate(schoolId, memberId); + if (isExistsBookmark(schoolId, memberId)) { + return; + } + bookmarkRepository.save(new Bookmark(BookmarkType.SCHOOL, schoolId, memberId)); + } + + private void validate(Long schoolId, Long memberId) { + if (!schoolRepository.existsById(schoolId)) { + throw new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND); + } + + long bookmarkCount = bookmarkRepository.countByMemberIdAndBookmarkType(memberId, BookmarkType.SCHOOL); + if (bookmarkCount >= MAX_SCHOOL_BOOKMARK_COUNT) { + throw new BadRequestException(ErrorCode.BOOKMARK_LIMIT_EXCEEDED); + } + } + + private boolean isExistsBookmark(Long schoolId, Long memberId) { + return bookmarkRepository.existsByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType.SCHOOL, + memberId, + schoolId + ); + } + + public void delete(Long schoolId, Long memberId) { + bookmarkRepository.deleteByBookmarkTypeAndMemberIdAndResourceId(BookmarkType.SCHOOL, memberId, schoolId); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/domain/Bookmark.java b/backend/src/main/java/com/festago/bookmark/domain/Bookmark.java new file mode 100644 index 000000000..ad6d22502 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/domain/Bookmark.java @@ -0,0 +1,67 @@ +package com.festago.bookmark.domain; + +import com.festago.common.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + uniqueConstraints = { + @UniqueConstraint( + columnNames = {"bookmark_type", "resource_id", "member_id"} + ) + } +) +public class Bookmark extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "bookmark_type", columnDefinition = "varchar") + private BookmarkType bookmarkType; + + @Column(name = "resource_id") + private Long resourceId; + + @Column(name = "member_id") + private Long memberId; + + public Bookmark(Long id, BookmarkType bookmarkType, Long resourceId, Long memberId) { + this.id = id; + this.bookmarkType = bookmarkType; + this.resourceId = resourceId; + this.memberId = memberId; + } + + public Bookmark(BookmarkType bookmarkType, Long resourceId, Long memberId) { + this(null, bookmarkType, resourceId, memberId); + } + + public Long getId() { + return id; + } + + public BookmarkType getBookmarkType() { + return bookmarkType; + } + + public Long getResourceId() { + return resourceId; + } + + public Long getMemberId() { + return memberId; + } +} diff --git a/backend/src/main/java/com/festago/bookmark/domain/BookmarkType.java b/backend/src/main/java/com/festago/bookmark/domain/BookmarkType.java new file mode 100644 index 000000000..84cca15aa --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/domain/BookmarkType.java @@ -0,0 +1,8 @@ +package com.festago.bookmark.domain; + +public enum BookmarkType { + SCHOOL, + ARTIST, + FESTIVAL + ; +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkInfoV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkInfoV1Response.java new file mode 100644 index 000000000..80dda8835 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkInfoV1Response.java @@ -0,0 +1,14 @@ +package com.festago.bookmark.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; + +public record ArtistBookmarkInfoV1Response( + Long id, + String name, + String profileImageUrl +) { + + @QueryProjection + public ArtistBookmarkInfoV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkV1Response.java new file mode 100644 index 000000000..54835c52c --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/ArtistBookmarkV1Response.java @@ -0,0 +1,14 @@ +package com.festago.bookmark.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; + +public record ArtistBookmarkV1Response( + ArtistBookmarkInfoV1Response artist, + LocalDateTime createdAt +) { + + @QueryProjection + public ArtistBookmarkV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/FestivalBookmarkV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/FestivalBookmarkV1Response.java new file mode 100644 index 000000000..0db51e03b --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/FestivalBookmarkV1Response.java @@ -0,0 +1,15 @@ +package com.festago.bookmark.dto.v1; + +import com.festago.festival.dto.FestivalV1Response; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; + +public record FestivalBookmarkV1Response( + FestivalV1Response festival, + LocalDateTime createdAt +) { + + @QueryProjection + public FestivalBookmarkV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkInfoV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkInfoV1Response.java new file mode 100644 index 000000000..d53f08690 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkInfoV1Response.java @@ -0,0 +1,14 @@ +package com.festago.bookmark.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; + +public record SchoolBookmarkInfoV1Response( + Long id, + String name, + String logoUrl +) { + + @QueryProjection + public SchoolBookmarkInfoV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkV1Response.java b/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkV1Response.java new file mode 100644 index 000000000..2c13ddeb1 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/dto/v1/SchoolBookmarkV1Response.java @@ -0,0 +1,14 @@ +package com.festago.bookmark.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; + +public record SchoolBookmarkV1Response( + SchoolBookmarkInfoV1Response school, + LocalDateTime createdAt +) { + + @QueryProjection + public SchoolBookmarkV1Response { + } +} diff --git a/backend/src/main/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1Controller.java b/backend/src/main/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1Controller.java new file mode 100644 index 000000000..d1b7f1216 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1Controller.java @@ -0,0 +1,32 @@ +package com.festago.bookmark.presentation.v1; + +import com.festago.auth.annotation.Member; +import com.festago.auth.annotation.MemberAuth; +import com.festago.bookmark.application.ArtistBookmarkV1QueryService; +import com.festago.bookmark.dto.v1.ArtistBookmarkV1Response; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/bookmarks/artists") +@Tag(name = "아티스트 북마크 요청 V1") +public class ArtistBookmarkV1Controller { + + private final ArtistBookmarkV1QueryService artistBookmarkV1QueryService; + + @MemberAuth + @GetMapping + @Operation(description = "회원의 아티스트 북마크 목록을 조회한다.", summary = "아티스트 북마크 조회") + public ResponseEntity> findArtistBookmarksByMemberId( + @Member Long memberId + ) { + return ResponseEntity.ok(artistBookmarkV1QueryService.findArtistBookmarksByMemberId(memberId)); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1Controller.java b/backend/src/main/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1Controller.java new file mode 100644 index 000000000..3fde1bb5c --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1Controller.java @@ -0,0 +1,50 @@ +package com.festago.bookmark.presentation.v1; + +import com.festago.auth.annotation.Member; +import com.festago.auth.annotation.MemberAuth; +import com.festago.bookmark.application.command.BookmarkFacadeService; +import com.festago.bookmark.domain.BookmarkType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/bookmarks") +@Tag(name = "북마크 등록/삭제 요청 V1") +public class BookmarkManagementV1Controller { + + private final BookmarkFacadeService bookmarkFacadeService; + + @MemberAuth + @PutMapping + @Operation(description = "자원의 식별자와 타입으로 북마크를 등록한다.", summary = "북마크 등록") + public ResponseEntity putBookmark( + @Member Long memberId, + @RequestParam Long resourceId, + @RequestParam BookmarkType bookmarkType + ) { + bookmarkFacadeService.save(bookmarkType, resourceId, memberId); + return ResponseEntity.ok() + .build(); + } + + @MemberAuth + @DeleteMapping + @Operation(description = "자원의 식별자와 타입으로 북마크를 삭제한다.", summary = "북마크 삭제") + public ResponseEntity deleteBookmark( + @Member Long memberId, + @RequestParam Long resourceId, + @RequestParam BookmarkType bookmarkType + ) { + bookmarkFacadeService.delete(bookmarkType, resourceId, memberId); + return ResponseEntity.noContent() + .build(); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1Controller.java b/backend/src/main/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1Controller.java new file mode 100644 index 000000000..e4f0b2ba5 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1Controller.java @@ -0,0 +1,51 @@ +package com.festago.bookmark.presentation.v1; + +import com.festago.auth.annotation.Member; +import com.festago.auth.annotation.MemberAuth; +import com.festago.bookmark.application.FestivalBookmarkV1QueryService; +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.bookmark.repository.FestivalBookmarkOrder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/bookmarks/festivals") +@Tag(name = "축제 북마크 요청 V1") +public class FestivalBookmarkV1Controller { + + private final FestivalBookmarkV1QueryService festivalBookmarkV1QueryService; + + @MemberAuth + @GetMapping("/ids") + @Operation(description = "회원의 북마크 된 축제 식별자 목록을 조회한다.", summary = "북마크 된 축제 식별자 목록 조회") + public ResponseEntity> findBookmarkedFestivalIds( + @Member Long memberId + ) { + return ResponseEntity.ok() + .body(festivalBookmarkV1QueryService.findBookmarkedFestivalIds(memberId)); + } + + @MemberAuth + @GetMapping + @Operation(description = "축제 식별자 목록으로 회원의 북마크 된 축제의 목록을 조회한다.", summary = "축제 식별자 목록으로 북마크 된 축제의 목록 조회") + public ResponseEntity> findBookmarkedFestivals( + @Member Long memberId, + @RequestParam List festivalIds, + @RequestParam FestivalBookmarkOrder festivalBookmarkOrder + ) { + return ResponseEntity.ok() + .body(festivalBookmarkV1QueryService.findBookmarkedFestivals( + memberId, + festivalIds, + festivalBookmarkOrder + )); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1Controller.java b/backend/src/main/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1Controller.java new file mode 100644 index 000000000..536d2d41e --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1Controller.java @@ -0,0 +1,30 @@ +package com.festago.bookmark.presentation.v1; + +import com.festago.auth.annotation.Member; +import com.festago.auth.annotation.MemberAuth; +import com.festago.bookmark.application.SchoolBookmarkV1QueryService; +import com.festago.bookmark.dto.v1.SchoolBookmarkV1Response; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/bookmarks/schools") +@Tag(name = "학교 북마크 요청 V1") +public class SchoolBookmarkV1Controller { + + private final SchoolBookmarkV1QueryService schoolBookmarkV1QueryService; + + @MemberAuth + @GetMapping + @Operation(description = "회원의 학교 북마크 목록을 조회한다", summary = "학교 북마크 조회") + public ResponseEntity> findAllByMemberId(@Member Long memberId) { + return ResponseEntity.ok(schoolBookmarkV1QueryService.findAllByMemberId(memberId)); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/BookmarkRepository.java b/backend/src/main/java/com/festago/bookmark/repository/BookmarkRepository.java new file mode 100644 index 000000000..83c99df43 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/BookmarkRepository.java @@ -0,0 +1,22 @@ +package com.festago.bookmark.repository; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import org.springframework.data.repository.Repository; + +public interface BookmarkRepository extends Repository { + + Bookmark save(Bookmark bookmark); + + void deleteById(Long id); + + boolean existsByBookmarkTypeAndMemberIdAndResourceId(BookmarkType bookmarkType, Long memberId, Long resourceId); + + long countByMemberIdAndBookmarkType(Long memberId, BookmarkType bookmarkType); + + void deleteByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType bookmarkType, + Long memberId, + Long resourceId + ); +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/FestivalBookmarkOrder.java b/backend/src/main/java/com/festago/bookmark/repository/FestivalBookmarkOrder.java new file mode 100644 index 000000000..640ebaaff --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/FestivalBookmarkOrder.java @@ -0,0 +1,7 @@ +package com.festago.bookmark.repository; + +public enum FestivalBookmarkOrder { + BOOKMARK, + FESTIVAL, + ; +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/v1/ArtistBookmarkV1QueryDslRepository.java b/backend/src/main/java/com/festago/bookmark/repository/v1/ArtistBookmarkV1QueryDslRepository.java new file mode 100644 index 000000000..62c64f4a4 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/v1/ArtistBookmarkV1QueryDslRepository.java @@ -0,0 +1,38 @@ +package com.festago.bookmark.repository.v1; + +import static com.festago.artist.domain.QArtist.artist; +import static com.festago.bookmark.domain.QBookmark.bookmark; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.ArtistBookmarkV1Response; +import com.festago.bookmark.dto.v1.QArtistBookmarkInfoV1Response; +import com.festago.bookmark.dto.v1.QArtistBookmarkV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistBookmarkV1QueryDslRepository extends QueryDslRepositorySupport { + + protected ArtistBookmarkV1QueryDslRepository() { + super(Bookmark.class); + } + + public List findByMemberId(Long memberId) { + return select( + new QArtistBookmarkV1Response( + new QArtistBookmarkInfoV1Response( + artist.id, + artist.name, + artist.profileImage + ), + bookmark.createdAt)) + .from(bookmark) + .innerJoin(artist).on( + bookmark.bookmarkType.eq(BookmarkType.ARTIST) + .and(bookmark.memberId.eq(memberId)) + .and(bookmark.resourceId.eq(artist.id))) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/v1/FestivalBookmarkV1QueryDslRepository.java b/backend/src/main/java/com/festago/bookmark/repository/v1/FestivalBookmarkV1QueryDslRepository.java new file mode 100644 index 000000000..d4a8d2f0d --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/v1/FestivalBookmarkV1QueryDslRepository.java @@ -0,0 +1,71 @@ +package com.festago.bookmark.repository.v1; + +import static com.festago.bookmark.domain.QBookmark.bookmark; +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.school.domain.QSchool.school; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.bookmark.dto.v1.QFestivalBookmarkV1Response; +import com.festago.bookmark.repository.FestivalBookmarkOrder; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.dto.QFestivalV1Response; +import com.festago.festival.dto.QSchoolV1Response; +import com.querydsl.core.types.OrderSpecifier; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class FestivalBookmarkV1QueryDslRepository extends QueryDslRepositorySupport { + + public FestivalBookmarkV1QueryDslRepository() { + super(Bookmark.class); + } + + public List findBookmarkedFestivalIds(Long memberId) { + return select(bookmark.resourceId) + .from(bookmark) + .where(bookmark.memberId.eq(memberId).and(bookmark.bookmarkType.eq(BookmarkType.FESTIVAL))) + .fetch(); + } + + public List findBookmarkedFestivals( + Long memberId, + List festivalIds, + FestivalBookmarkOrder festivalBookmarkOrder + ) { + return select( + new QFestivalBookmarkV1Response( + new QFestivalV1Response( + festival.id, + festival.name, + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + festival.posterImageUrl, + new QSchoolV1Response( + school.id, + school.name + ), + festivalQueryInfo.artistInfo + ), + bookmark.createdAt + )) + .from(festival) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .innerJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) + .innerJoin(bookmark).on(bookmark.bookmarkType.eq(BookmarkType.FESTIVAL) + .and(bookmark.resourceId.eq(festival.id)).and(bookmark.memberId.eq(memberId))) + .where(festival.id.in(festivalIds)) + .orderBy(dynamicOrder(festivalBookmarkOrder)) + .fetch(); + } + + private OrderSpecifier dynamicOrder(FestivalBookmarkOrder festivalBookmarkOrder) { + return switch (festivalBookmarkOrder) { + case BOOKMARK -> bookmark.id.desc(); + case FESTIVAL -> festival.festivalDuration.startDate.asc(); + }; + } +} diff --git a/backend/src/main/java/com/festago/bookmark/repository/v1/SchoolBookmarkV1QuerydslRepository.java b/backend/src/main/java/com/festago/bookmark/repository/v1/SchoolBookmarkV1QuerydslRepository.java new file mode 100644 index 000000000..b277fc066 --- /dev/null +++ b/backend/src/main/java/com/festago/bookmark/repository/v1/SchoolBookmarkV1QuerydslRepository.java @@ -0,0 +1,37 @@ +package com.festago.bookmark.repository.v1; + +import static com.festago.bookmark.domain.QBookmark.bookmark; +import static com.festago.school.domain.QSchool.school; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.QSchoolBookmarkInfoV1Response; +import com.festago.bookmark.dto.v1.QSchoolBookmarkV1Response; +import com.festago.bookmark.dto.v1.SchoolBookmarkV1Response; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class SchoolBookmarkV1QuerydslRepository extends QueryDslRepositorySupport { + + protected SchoolBookmarkV1QuerydslRepository() { + super(Bookmark.class); + } + + public List findAllByMemberId(Long memberId) { + return select(new QSchoolBookmarkV1Response( + new QSchoolBookmarkInfoV1Response( + school.id, + school.name, + school.logoUrl + ), + bookmark.createdAt + )) + .from(bookmark) + .innerJoin(school).on(school.id.eq(bookmark.resourceId) + .and(bookmark.memberId.eq(memberId)) + .and(bookmark.bookmarkType.eq(BookmarkType.SCHOOL))) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/common/aop/LogRequestBodyAspect.java b/backend/src/main/java/com/festago/common/aop/LogRequestBodyAspect.java index e00ad0e9e..8efb02a71 100644 --- a/backend/src/main/java/com/festago/common/aop/LogRequestBodyAspect.java +++ b/backend/src/main/java/com/festago/common/aop/LogRequestBodyAspect.java @@ -1,8 +1,6 @@ package com.festago.common.aop; import com.fasterxml.jackson.databind.ObjectMapper; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.lang.reflect.Method; @@ -10,11 +8,11 @@ import java.util.Map; import java.util.Objects; import java.util.function.BiConsumer; +import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; -import org.slf4j.Logger; import org.slf4j.event.Level; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; @@ -22,6 +20,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.util.ContentCachingRequestWrapper; +@Slf4j @Component @Aspect public class LogRequestBodyAspect { @@ -31,14 +30,12 @@ public class LogRequestBodyAspect { private final Map> loggerMap = new EnumMap<>(Level.class); private final ObjectMapper objectMapper; - private final Logger errorLogger; - public LogRequestBodyAspect(ObjectMapper objectMapper, Logger errorLogger) { + public LogRequestBodyAspect(ObjectMapper objectMapper) { this.objectMapper = objectMapper; - this.errorLogger = errorLogger; - loggerMap.put(Level.INFO, this.errorLogger::info); - loggerMap.put(Level.WARN, this.errorLogger::warn); - loggerMap.put(Level.ERROR, this.errorLogger::error); + loggerMap.put(Level.INFO, log::info); + loggerMap.put(Level.WARN, log::warn); + loggerMap.put(Level.ERROR, log::error); } @Around("@annotation(LogRequestBody)") @@ -49,7 +46,7 @@ public Object handleAll(ProceedingJoinPoint pjp) throws Throwable { Level level = annotation.level(); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - if (attributes == null || !errorLogger.isEnabledForLevel(level)) { + if (attributes == null || !log.isEnabledForLevel(level)) { return pjp.proceed(); } HttpServletRequest request = attributes.getRequest(); @@ -85,8 +82,11 @@ private String getRequestPayload(HttpServletRequest request) { try { ContentCachingRequestWrapper cachedRequest = (ContentCachingRequestWrapper) request; return objectMapper.readTree(cachedRequest.getContentAsByteArray()).toPrettyString(); - } catch (IOException | ClassCastException e) { - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + } catch (IOException e) { + log.warn("ObjectMapper에서 직렬화 중에 문제가 발생했습니다.", e); + } catch (ClassCastException e) { + log.warn("HttpServletRequest 객체를 ContentCachingRequestWrapper 타입으로 형변환 하는 중 문제가 발생했습니다.", e); } + return "[ObjectMapper에서 직렬화 중에 문제가 발생했습니다.]"; } } diff --git a/backend/src/main/java/com/festago/common/aop/ValidPageable.java b/backend/src/main/java/com/festago/common/aop/ValidPageable.java new file mode 100644 index 000000000..41d14bbb9 --- /dev/null +++ b/backend/src/main/java/com/festago/common/aop/ValidPageable.java @@ -0,0 +1,15 @@ +package com.festago.common.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPageable { + + int maxSize() default 20; + + String sizeKey() default "size"; +} diff --git a/backend/src/main/java/com/festago/common/aop/ValidPageableAspect.java b/backend/src/main/java/com/festago/common/aop/ValidPageableAspect.java new file mode 100644 index 000000000..069af4008 --- /dev/null +++ b/backend/src/main/java/com/festago/common/aop/ValidPageableAspect.java @@ -0,0 +1,61 @@ +package com.festago.common.aop; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import java.lang.reflect.Method; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Component +@Aspect +public class ValidPageableAspect { + + @Before("@annotation(ValidPageable)") + public void doValid(JoinPoint joinPoint) { + ValidPageable validPageable = getAnnotation(joinPoint); + String sizeKey = validPageable.sizeKey(); + int maxSize = validPageable.maxSize(); + + String sizeString = getSizeString(sizeKey); + if (!StringUtils.hasText(sizeString)) { // 쿼리 파라미터에 size가 없을 경우 spring의 기본 값 사용 + return; + } + int size = parseSize(sizeString); + validateMaxSize(size, maxSize); + } + + private ValidPageable getAnnotation(JoinPoint joinPoint) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + return method.getAnnotation(ValidPageable.class); + } + + private String getSizeString(String key) { + HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + return request.getParameter(key); + } + + private int parseSize(String size) { + try { + return Integer.parseInt(size); + } catch (NumberFormatException e) { + throw new BadRequestException(ErrorCode.INVALID_NUMBER_FORMAT_PAGING_SIZE); + } + } + + private void validateMaxSize(int size, int maxSize) { + if (size < 1) { + throw new BadRequestException(ErrorCode.INVALID_NUMBER_FORMAT_PAGING_SIZE); + } + if (maxSize < size) { + throw new BadRequestException(ErrorCode.INVALID_PAGING_MAX_SIZE); + } + } +} diff --git a/backend/src/main/java/com/festago/common/dto/SliceResponse.java b/backend/src/main/java/com/festago/common/dto/SliceResponse.java new file mode 100644 index 000000000..7368ce541 --- /dev/null +++ b/backend/src/main/java/com/festago/common/dto/SliceResponse.java @@ -0,0 +1,14 @@ +package com.festago.common.dto; + +import java.util.List; +import org.springframework.data.domain.Slice; + +public record SliceResponse( + boolean last, + List content +) { + + public static SliceResponse from(Slice slice) { + return new SliceResponse(slice.isLast(), slice.getContent()); + } +} diff --git a/backend/src/main/java/com/festago/common/exception/ErrorCode.java b/backend/src/main/java/com/festago/common/exception/ErrorCode.java index 9c57b4cab..146d5d174 100644 --- a/backend/src/main/java/com/festago/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/festago/common/exception/ErrorCode.java @@ -9,14 +9,13 @@ public enum ErrorCode { INVALID_ENTRY_CODE("올바르지 않은 입장코드입니다."), INVALID_TICKET_OPEN_TIME("티켓 오픈 시간은 공연 시작 이전 이어야 합니다."), INVALID_STAGE_START_TIME("공연은 축제 기간 중에만 진행될 수 있습니다."), - INVALID_MIN_TICKET_AMOUNT("티켓은 적어도 한장 이상 발급해야합니다."), LATE_TICKET_ENTRY_TIME("입장 시간은 공연 시간보다 빨라야합니다."), EARLY_TICKET_ENTRY_TIME("입장 시간은 공연 시작 12시간 이내여야 합니다."), EARLY_TICKET_ENTRY_THAN_OPEN("입장 시간은 티켓 오픈 시간 이후여야합니다."), TICKET_SOLD_OUT("매진된 티켓입니다."), INVALID_FESTIVAL_DURATION("축제 시작 일은 종료일 이전이어야 합니다."), - INVALID_FESTIVAL_START_DATE("축제 시작 일은 과거일 수 없습니다."), - INVALID_TICKET_CREATE_TIME("티켓 오픈 시간 이후 새롭게 티켓을 발급할 수 없습니다."), + INVALID_FESTIVAL_START_DATE("축제 시작 일자는 과거일 수 없습니다."), + INVALID_TICKET_CREATE_TIME("티켓 예매 시작 후 새롭게 티켓을 발급할 수 없습니다."), OAUTH2_NOT_SUPPORTED_SOCIAL_TYPE("해당 OAuth2 제공자는 지원되지 않습니다."), RESERVE_TICKET_OVER_AMOUNT("예매 가능한 수량을 초과했습니다."), NEED_STUDENT_VERIFICATION("학생 인증이 필요합니다."), @@ -27,9 +26,27 @@ public enum ErrorCode { INVALID_STUDENT_VERIFICATION_CODE("올바르지 않은 학생 인증 코드입니다."), DELETE_CONSTRAINT_FESTIVAL("공연이 등록된 축제는 삭제할 수 없습니다."), DELETE_CONSTRAINT_STAGE("티켓이 등록된 공연은 삭제할 수 없습니다."), - DELETE_CONSTRAINT_SCHOOL("학생 또는 축제에 등록된 학교는 삭제할 수 없습니다."), - DUPLICATE_SCHOOL("이미 존재하는 학교 정보입니다."), - + DELETE_CONSTRAINT_SCHOOL("학생 또는 축제에 등록된 학교는 삭제할 수 없습니다."), // @deprecate + DUPLICATE_SCHOOL("이미 존재하는 학교 정보입니다."), // @deprecate + VALIDATION_FAIL("검증이 실패하였습니다."), + INVALID_FESTIVAL_FILTER("유효하지 않은 축제의 필터 값입니다."), + SCHOOL_DELETE_CONSTRAINT_EXISTS_STUDENT("학생이 등록된 학교는 삭제할 수 없습니다."), + SCHOOL_DELETE_CONSTRAINT_EXISTS_FESTIVAL("축제가 등록된 학교는 삭제할 수 없습니다."), + DUPLICATE_SCHOOL_NAME("이미 존재하는 학교의 이름입니다."), + DUPLICATE_SCHOOL_DOMAIN("이미 존재하는 학교의 도메인입니다."), + INVALID_PAGING_MAX_SIZE("최대 size 값을 초과했습니다."), + INVALID_NUMBER_FORMAT_PAGING_SIZE("size는 1 이상의 정수 형식이어야 합니다."), + FESTIVAL_DELETE_CONSTRAINT_EXISTS_STAGE("공연이 등록된 축제는 삭제할 수 없습니다."), + FESTIVAL_UPDATE_OUT_OF_DATE_STAGE_START_TIME("축제에 등록된 공연 중 변경하려는 날짜에 포함되지 않는 공연이 있습니다."), + BOOKMARK_LIMIT_EXCEEDED("최대 북마크 갯수를 초과했습니다"), + BROAD_SEARCH_KEYWORD("더 자세한 검색어로 입력해야합니다."), + INVALID_KEYWORD("유효하지 않은 키워드 입니다."), + DUPLICATE_SOCIAL_MEDIA("이미 존재하는 소셜미디어 입니다."), + OAUTH2_INVALID_CODE("잘못된 OAuth2 Code 입니다."), + OPEN_ID_NOT_SUPPORTED_SOCIAL_TYPE("해당 OpenId 제공자는 지원되지 않습니다."), + OPEN_ID_INVALID_TOKEN("잘못된 OpenID 토큰입니다."), + NOT_SUPPORT_FILE_EXTENSION("해당 파일의 확장자는 허용되지 않습니다."), + DUPLICATE_ARTIST_NAME("이미 존재하는 아티스트의 이름입니다."), // 401 EXPIRED_AUTH_TOKEN("만료된 로그인 토큰입니다."), @@ -38,6 +55,8 @@ public enum ErrorCode { NEED_AUTH_TOKEN("로그인이 필요한 서비스입니다."), INCORRECT_PASSWORD_OR_ACCOUNT("비밀번호가 틀렸거나, 해당 계정이 없습니다."), DUPLICATE_ACCOUNT_USERNAME("해당 계정이 존재합니다."), + INVALID_REFRESH_TOKEN("존재하지 않은 리프래쉬 토큰으로 재발급 요청을 했습니다."), + EXPIRED_REFRESH_TOKEN("만료된 리프래쉬 토큰입니다."), // 403 NOT_ENOUGH_PERMISSION("해당 권한이 없습니다."), @@ -49,24 +68,23 @@ public enum ErrorCode { FESTIVAL_NOT_FOUND("존재하지 않는 축제입니다."), TICKET_NOT_FOUND("존재하지 않는 티켓입니다."), SCHOOL_NOT_FOUND("존재하지 않는 학교입니다."), + ARTIST_NOT_FOUND("존재하지 않는 아티스트입니다."), + SOCIAL_MEDIA_NOT_FOUND("존재하지 않는 소셜미디어입니다."), + ACTUATOR_NOT_FOUND("존재하지 않는 Actuator 경로입니다."), // 429 TOO_FREQUENT_REQUESTS("너무 잦은 요청입니다. 잠시 후 다시 시도해주세요."), // 500 INTERNAL_SERVER_ERROR("서버 내부에 문제가 발생했습니다."), - INVALID_ENTRY_CODE_PERIOD("올바르지 않은 입장코드 유효기간입니다."), - INVALID_ENTRY_CODE_EXPIRATION_TIME("올바르지 않은 입장코드 만료 일자입니다."), - INVALID_ENTRY_STATE_INDEX("올바르지 않은 입장상태 인덱스입니다."), - INVALID_ENTRY_CODE_PAYLOAD("유효하지 않은 입장코드 payload 입니다."), - INVALID_AUTH_TOKEN_PAYLOAD("유효하지 않은 로그인 토큰 payload 입니다."), - DUPLICATE_SOCIAL_TYPE("중복된 OAuth2 제공자 입니다."), OAUTH2_PROVIDER_NOT_RESPONSE("OAuth2 제공자 서버에 문제가 발생했습니다."), - INVALID_ENTRY_CODE_OFFSET("올바르지 않은 입장코드 오프셋입니다."), - INVALID_ROLE_NAME("해당하는 Role이 없습니다."), FOR_TEST_ERROR("테스트용 에러입니다."), FAIL_SEND_FCM_MESSAGE("FCM Message 전송에 실패했습니다."), - FCM_NOT_FOUND("유효하지 않은 MemberFCM 이 감지 되었습니다."); + TICKET_SEQUENCE_DATA_ERROR("입장 순서 값의 데이터 정합성에 문제가 발생했습니다."), + OAUTH2_INVALID_REQUEST("알 수 없는 OAuth2 에러가 발생했습니다."), + OPEN_ID_PROVIDER_NOT_RESPONSE("OpenID 제공자 서버에 문제가 발생했습니다."), + FILE_UPLOAD_ERROR("파일 업로드 중 에러가 발생했습니다."), + ; private final String message; diff --git a/backend/src/main/java/com/festago/common/exception/FestaGoException.java b/backend/src/main/java/com/festago/common/exception/FestaGoException.java index 4bbdab694..0c3baf81c 100644 --- a/backend/src/main/java/com/festago/common/exception/FestaGoException.java +++ b/backend/src/main/java/com/festago/common/exception/FestaGoException.java @@ -16,6 +16,11 @@ protected FestaGoException(ErrorCode errorCode, Throwable cause) { this.errorCode = errorCode; } + protected FestaGoException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + public ErrorCode getErrorCode() { return errorCode; } diff --git a/backend/src/main/java/com/festago/common/exception/UnexpectedException.java b/backend/src/main/java/com/festago/common/exception/UnexpectedException.java new file mode 100644 index 000000000..a5414da39 --- /dev/null +++ b/backend/src/main/java/com/festago/common/exception/UnexpectedException.java @@ -0,0 +1,8 @@ +package com.festago.common.exception; + +public class UnexpectedException extends FestaGoException { + + public UnexpectedException(String message) { + super(ErrorCode.INTERNAL_SERVER_ERROR, message); + } +} diff --git a/backend/src/main/java/com/festago/common/exception/ValidException.java b/backend/src/main/java/com/festago/common/exception/ValidException.java new file mode 100644 index 000000000..08154be5b --- /dev/null +++ b/backend/src/main/java/com/festago/common/exception/ValidException.java @@ -0,0 +1,8 @@ +package com.festago.common.exception; + +public class ValidException extends FestaGoException { + + public ValidException(String message) { + super(ErrorCode.VALIDATION_FAIL, message); + } +} diff --git a/backend/src/main/java/com/festago/common/exception/dto/ErrorResponse.java b/backend/src/main/java/com/festago/common/exception/dto/ErrorResponse.java index a15d330fc..110bf7866 100644 --- a/backend/src/main/java/com/festago/common/exception/dto/ErrorResponse.java +++ b/backend/src/main/java/com/festago/common/exception/dto/ErrorResponse.java @@ -2,17 +2,13 @@ import com.festago.common.exception.ErrorCode; import com.festago.common.exception.FestaGoException; -import java.util.List; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; +import com.festago.common.exception.ValidException; public record ErrorResponse( ErrorCode errorCode, String message ) { - private static final String NOT_CUSTOM_EXCEPTION = "Validation failed"; - public static ErrorResponse from(FestaGoException festaGoException) { return ErrorResponse.from(festaGoException.getErrorCode()); } @@ -21,14 +17,7 @@ public static ErrorResponse from(ErrorCode errorCode) { return new ErrorResponse(errorCode, errorCode.getMessage()); } - public static ErrorResponse from(ErrorCode errorCode, MethodArgumentNotValidException e) { - List fieldErrors = e.getBindingResult().getFieldErrors(); - if (fieldErrors.isEmpty()) { - return new ErrorResponse(errorCode, errorCode.getMessage()); - } - if (e.getMessage().startsWith(NOT_CUSTOM_EXCEPTION)) { - return new ErrorResponse(errorCode, fieldErrors.get(0).getDefaultMessage()); - } - return new ErrorResponse(errorCode, e.getMessage()); + public static ErrorResponse from(ValidException e) { + return new ErrorResponse(e.getErrorCode(), e.getMessage()); } } diff --git a/backend/src/main/java/com/festago/common/exception/dto/ValidErrorResponse.java b/backend/src/main/java/com/festago/common/exception/dto/ValidErrorResponse.java new file mode 100644 index 000000000..6874008f2 --- /dev/null +++ b/backend/src/main/java/com/festago/common/exception/dto/ValidErrorResponse.java @@ -0,0 +1,33 @@ +package com.festago.common.exception.dto; + +import static java.util.stream.Collectors.toMap; + +import com.festago.common.exception.ErrorCode; +import java.util.Map; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +public record ValidErrorResponse( + ErrorCode errorCode, + String message, + Map result +) { + + public static ValidErrorResponse from(MethodArgumentNotValidException e) { + Map result = e.getBindingResult().getFieldErrors().stream() + .collect(toMap(FieldError::getField, ValidErrorResponse::getFieldErrorMessage)); + return new ValidErrorResponse( + ErrorCode.INVALID_REQUEST_ARGUMENT, + ErrorCode.INVALID_REQUEST_ARGUMENT.getMessage(), + result + ); + } + + private static String getFieldErrorMessage(FieldError error) { + String message = error.getDefaultMessage(); + if (message == null) { + return "잘못된 요청입니다."; + } + return message; + } +} diff --git a/backend/src/main/java/com/festago/common/filter/wrapping/UriPatternInitializer.java b/backend/src/main/java/com/festago/common/filter/wrapping/UriPatternInitializer.java new file mode 100644 index 000000000..37007ced9 --- /dev/null +++ b/backend/src/main/java/com/festago/common/filter/wrapping/UriPatternInitializer.java @@ -0,0 +1,36 @@ +package com.festago.common.filter.wrapping; + +import com.festago.common.aop.LogRequestBody; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +/** + * ApplicationReadyEvent를 통해 Lazy하게 UriPatternMatcher의 패턴을 추가하는 클래스
+ */ +@Component +@RequiredArgsConstructor +public class UriPatternInitializer { + + private final RequestMappingHandlerMapping requestMappingHandlerMapping; + private final UriPatternMatcher uriPatternMatcher; + + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + for (var entry : requestMappingHandlerMapping.getHandlerMethods().entrySet()) { + RequestMappingInfo requestMappingInfo = entry.getKey(); + HandlerMethod handlerMethod = entry.getValue(); + if (handlerMethod.hasMethodAnnotation(LogRequestBody.class)) { + Set methods = requestMappingInfo.getMethodsCondition().getMethods(); + Set directPaths = requestMappingInfo.getDirectPaths(); + uriPatternMatcher.addPattern(methods, directPaths); + } + } + } +} diff --git a/backend/src/main/java/com/festago/common/filter/wrapping/UriPatternMatcher.java b/backend/src/main/java/com/festago/common/filter/wrapping/UriPatternMatcher.java new file mode 100644 index 000000000..9f839cd0e --- /dev/null +++ b/backend/src/main/java/com/festago/common/filter/wrapping/UriPatternMatcher.java @@ -0,0 +1,48 @@ +package com.festago.common.filter.wrapping; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.bind.annotation.RequestMethod; + +@Component +public class UriPatternMatcher { + + private final AntPathMatcher antPathMatcher = new AntPathMatcher(); + private final Map> methodToPatterns = new EnumMap<>(RequestMethod.class); + + /** + * HttpMethod 목록에 대해 Pattern에 추가될 URI Path 목록을 추가하는 메서드
어플리케이션이 실행될 때, 하나의 스레드에서 접근하는 것을 가정으로 설계했기에 Thread Safe + * 하지 않음.
따라서 다른 Bean에서 해당 클래스를 의존하여, 이 메서드를 호출하는 것에 주의할 것 + * + * @param methods 패턴에 추가할 HttpMethod 목록 + * @param paths 패턴에 추가할 URI 목록 + */ + public void addPattern(Set methods, Set paths) { + for (RequestMethod method : methods) { + Set patterns = methodToPatterns.computeIfAbsent(method, ignore -> new HashSet<>()); + patterns.addAll(paths); + } + } + + /** + * HttpMethod와 Path이 등록된 패턴에 일치하는지 검사하는 메서드 + * + * @param method 패턴에 일치하는지 검사할 HttpMethod + * @param path 패턴에 일치하는지 검사할 경로. 예시: "/api/v1/festival" + * @return method에 대한 path가 등록된 패턴에 일치하면 true, 아니면 false + */ + public boolean match(RequestMethod method, String path) { + Set patterns = methodToPatterns.getOrDefault(method, Collections.emptySet()); + for (String pattern : patterns) { + if (antPathMatcher.match(pattern, path)) { + return true; + } + } + return false; + } +} diff --git a/backend/src/main/java/com/festago/common/filter/wrapping/UriPatternRequestWrappingFilter.java b/backend/src/main/java/com/festago/common/filter/wrapping/UriPatternRequestWrappingFilter.java new file mode 100644 index 000000000..c66b72a38 --- /dev/null +++ b/backend/src/main/java/com/festago/common/filter/wrapping/UriPatternRequestWrappingFilter.java @@ -0,0 +1,39 @@ +package com.festago.common.filter.wrapping; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; + +/** + * LogRequestBodyAspect 클래스가 해당 클래스에 의존하므로, 해당 클래스 수정, 삭제 시 LogRequestBodyAspect 클래스도 수정하거나 삭제할 것! + */ +@Profile("!test") +@Component +@RequiredArgsConstructor +public class UriPatternRequestWrappingFilter extends OncePerRequestFilter { + + private final UriPatternMatcher uriPatternMatcher; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain + ) throws ServletException, IOException { + if (uriPatternMatcher.match(RequestMethod.resolve(request.getMethod()), request.getRequestURI())) { + ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request); + chain.doFilter(wrappingRequest, response); + } else { + chain.doFilter(request, response); + } + } +} + diff --git a/backend/src/main/java/com/festago/common/handler/GlobalExceptionHandler.java b/backend/src/main/java/com/festago/common/handler/GlobalExceptionHandler.java new file mode 100644 index 000000000..b26be3752 --- /dev/null +++ b/backend/src/main/java/com/festago/common/handler/GlobalExceptionHandler.java @@ -0,0 +1,156 @@ +package com.festago.common.handler; + +import com.festago.auth.AuthenticateContext; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.FestaGoException; +import com.festago.common.exception.ForbiddenException; +import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.NotFoundException; +import com.festago.common.exception.TooManyRequestException; +import com.festago.common.exception.UnauthorizedException; +import com.festago.common.exception.UnexpectedException; +import com.festago.common.exception.ValidException; +import com.festago.common.exception.dto.ErrorResponse; +import com.festago.common.exception.dto.ValidErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.apache.catalina.connector.ClientAbortException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger("ErrorLogger"); + private static final String LOG_FORMAT_INFO = "\n[🔵INFO] - ({} {})\n(id: {}, role: {})\n{}\n {}: {}"; + private static final String LOG_FORMAT_WARN = "\n[🟠WARN] - ({} {})\n(id: {}, role: {})"; + private static final String LOG_FORMAT_ERROR = "\n[🔴ERROR] - ({} {})\n(id: {}, role: {})"; + // INFO + /* + [🔵INFO] - (POST /admin/info) + (id: 1, role: MEMBER) + FOR_TEST_ERROR + com.festago.exception.BadRequestException: 테스트용 에러입니다. + */ + + // WARN + /* + [🟠WARN] - (POST /admin/warn) + (id: 1, role: MEMBER) + FOR_TEST_ERROR + com.festago.exception.InternalServerException: 테스트용 에러입니다. + at com.festago.admin.presentation.AdminController.getWarn(AdminController.java:129) + */ + + // ERROR + /* + [🔴ERROR] - (POST /admin/error) + (id: 1, role: MEMBER) + java.lang.IllegalArgumentException: 테스트용 에러입니다. + at com.festago.admin.presentation.AdminController.getError(AdminController.java:129) + */ + + private final AuthenticateContext authenticateContext; + + @ExceptionHandler(ClientAbortException.class) + public ResponseEntity handle(ClientAbortException e) { + return ResponseEntity.badRequest().build(); + } + + @ExceptionHandler(InvalidMediaTypeException.class) + public ResponseEntity handle(InvalidMediaTypeException e) { + return ResponseEntity.badRequest().build(); + } + + @ExceptionHandler(ValidException.class) + public ResponseEntity handle(ValidException e) { + return ResponseEntity.badRequest().body(ErrorResponse.from(e)); + } + + @ExceptionHandler(UnexpectedException.class) + public ResponseEntity handle(UnexpectedException e) { + return ResponseEntity.internalServerError().body(ErrorResponse.from(e)); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handle(BadRequestException e, HttpServletRequest request) { + logInfo(e, request); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.from(e)); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handle(UnauthorizedException e, HttpServletRequest request) { + logInfo(e, request); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ErrorResponse.from(e)); + } + + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handle(ForbiddenException e, HttpServletRequest request) { + logInfo(e, request); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse.from(e)); + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handle(NotFoundException e, HttpServletRequest request) { + logInfo(e, request); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse.from(e)); + } + + @ExceptionHandler(TooManyRequestException.class) + public ResponseEntity handle(TooManyRequestException e, HttpServletRequest request) { + logInfo(e, request); + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(ErrorResponse.from(e)); + } + + @ExceptionHandler(InternalServerException.class) + public ResponseEntity handle(InternalServerException e, HttpServletRequest request) { + logWarn(e, request); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handle(Exception e, HttpServletRequest request) { + logError(e, request); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR)); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ValidErrorResponse.from(e)); + } + + private void logInfo(FestaGoException e, HttpServletRequest request) { + log.info(LOG_FORMAT_INFO, request.getMethod(), request.getRequestURI(), authenticateContext.getId(), + authenticateContext.getRole(), e.getErrorCode(), e.getClass().getName(), e.getMessage()); + } + + private void logWarn(FestaGoException e, HttpServletRequest request) { + log.warn(LOG_FORMAT_WARN, request.getMethod(), request.getRequestURI(), + authenticateContext.getId(), authenticateContext.getRole(), e); + } + + private void logError(Exception e, HttpServletRequest request) { + log.error(LOG_FORMAT_ERROR, request.getMethod(), request.getRequestURI(), + authenticateContext.getId(), authenticateContext.getRole(), e); + } +} diff --git a/backend/src/main/java/com/festago/common/interceptor/AnnotationDelegateInterceptor.java b/backend/src/main/java/com/festago/common/interceptor/AnnotationDelegateInterceptor.java new file mode 100644 index 000000000..05a29a32c --- /dev/null +++ b/backend/src/main/java/com/festago/common/interceptor/AnnotationDelegateInterceptor.java @@ -0,0 +1,62 @@ +package com.festago.common.interceptor; + +import com.festago.common.exception.UnexpectedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.lang.annotation.Annotation; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +public class AnnotationDelegateInterceptor implements HandlerInterceptor { + + private final Class annotation; + private final HandlerInterceptor interceptor; + + protected AnnotationDelegateInterceptor( + Class annotation, + HandlerInterceptor interceptor + ) { + if (annotation == null) { + throw new UnexpectedException("annotation은 null이 될 수 없습니다."); + } + if (interceptor == null) { + throw new UnexpectedException("interceptor는 null이 될 수 없습니다."); + } + this.annotation = annotation; + this.interceptor = interceptor; + } + + public static AnnotationsDelegateInterceptorBuilder builder() { + return new AnnotationsDelegateInterceptorBuilder(); + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + HandlerMethod handlerMethod = (HandlerMethod) handler; + if (handlerMethod.hasMethodAnnotation(annotation)) { + return interceptor.preHandle(request, response, handler); + } + return true; + } + + public static class AnnotationsDelegateInterceptorBuilder { + + private Class annotation; + private HandlerInterceptor interceptor; + + public AnnotationsDelegateInterceptorBuilder annotation(Class annotation) { + this.annotation = annotation; + return this; + } + + public AnnotationsDelegateInterceptorBuilder interceptor(HandlerInterceptor interceptor) { + this.interceptor = interceptor; + return this; + } + + public AnnotationDelegateInterceptor build() { + return new AnnotationDelegateInterceptor(annotation, interceptor); + } + } +} diff --git a/backend/src/main/java/com/festago/common/interceptor/HttpMethodDelegateInterceptor.java b/backend/src/main/java/com/festago/common/interceptor/HttpMethodDelegateInterceptor.java new file mode 100644 index 000000000..b6f7b4e8d --- /dev/null +++ b/backend/src/main/java/com/festago/common/interceptor/HttpMethodDelegateInterceptor.java @@ -0,0 +1,58 @@ +package com.festago.common.interceptor; + +import static java.util.stream.Collectors.toUnmodifiableSet; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +public class HttpMethodDelegateInterceptor implements HandlerInterceptor { + + private final Set allowMethods; + private final HandlerInterceptor interceptor; + + protected HttpMethodDelegateInterceptor(Set allowMethods, HandlerInterceptor interceptor) { + this.allowMethods = allowMethods; + this.interceptor = interceptor; + } + + public static HttpMethodDelegateInterceptorBuilder builder() { + return new HttpMethodDelegateInterceptorBuilder(); + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + if (allowMethods.contains(request.getMethod())) { + return interceptor.preHandle(request, response, handler); + } + return true; + } + + public static class HttpMethodDelegateInterceptorBuilder { + + private final Set allowMethod = new HashSet<>(); + private HandlerInterceptor interceptor; + + public HttpMethodDelegateInterceptorBuilder allowMethod(HttpMethod... httpMethods) { + allowMethod.addAll(Arrays.asList(httpMethods)); + return this; + } + + public HttpMethodDelegateInterceptorBuilder interceptor(HandlerInterceptor interceptor) { + this.interceptor = interceptor; + return this; + } + + public HttpMethodDelegateInterceptor build() { + Set methods = allowMethod.stream() + .map(HttpMethod::name) + .collect(toUnmodifiableSet()); + return new HttpMethodDelegateInterceptor(methods, interceptor); + } + } +} diff --git a/backend/src/main/java/com/festago/common/presentation/PingController.java b/backend/src/main/java/com/festago/common/presentation/PingController.java new file mode 100644 index 000000000..127bff958 --- /dev/null +++ b/backend/src/main/java/com/festago/common/presentation/PingController.java @@ -0,0 +1,17 @@ +package com.festago.common.presentation; + +import io.swagger.v3.oas.annotations.Hidden; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Hidden +@RestController +@RequestMapping("/ping") +public class PingController { + + @GetMapping + public String ping() { + return "pong"; + } +} diff --git a/backend/src/main/java/com/festago/common/querydsl/OrderSpecifierUtils.java b/backend/src/main/java/com/festago/common/querydsl/OrderSpecifierUtils.java new file mode 100644 index 000000000..8c9befb5a --- /dev/null +++ b/backend/src/main/java/com/festago/common/querydsl/OrderSpecifierUtils.java @@ -0,0 +1,20 @@ +package com.festago.common.querydsl; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.NullExpression; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import org.springframework.data.domain.Sort; + +public class OrderSpecifierUtils { + + public static final OrderSpecifier NULL = new OrderSpecifier(Order.ASC, NullExpression.DEFAULT, + OrderSpecifier.NullHandling.Default); + + private OrderSpecifierUtils() { + } + + public static OrderSpecifier of(Sort.Direction direction, Expression target) { + return new OrderSpecifier(direction.isAscending() ? Order.ASC : Order.DESC, target); + } +} diff --git a/backend/src/main/java/com/festago/common/querydsl/QueryDslHelper.java b/backend/src/main/java/com/festago/common/querydsl/QueryDslHelper.java new file mode 100644 index 000000000..4af68e284 --- /dev/null +++ b/backend/src/main/java/com/festago/common/querydsl/QueryDslHelper.java @@ -0,0 +1,65 @@ +package com.festago.common.querydsl; + +import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.Expression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class QueryDslHelper { + + private static final int NEXT_PAGE_OFFSET = 1; + private final JPAQueryFactory queryFactory; + + public JPAQuery select(Expression expr) { + return queryFactory.select(expr); + } + + public JPAQuery selectFrom(EntityPath expr) { + return queryFactory.selectFrom(expr); + } + + public Optional fetchOne(Function> queryFunction) { + JPAQuery query = queryFunction.apply(queryFactory); + return Optional.ofNullable(query.fetchOne()); + } + + public Page applyPagination( + Pageable pageable, + Function> contentQueryFunction, + Function> countQueryFunction + ) { + List content = contentQueryFunction.apply(queryFactory).fetch(); + JPAQuery countQuery = countQueryFunction.apply(queryFactory); + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + public Slice applySlice( + Pageable pageable, + Function> contentQueryFunction + ) { + List content = contentQueryFunction.apply(queryFactory) + .limit(pageable.getPageSize() + (long) NEXT_PAGE_OFFSET) + .fetch(); + if (content.size() > pageable.getPageSize()) { + removeTemporaryContent(content); + return new SliceImpl<>(content, pageable, true); + } + return new SliceImpl<>(content, pageable, false); + } + + private void removeTemporaryContent(List content) { + content.remove(content.size() - NEXT_PAGE_OFFSET); + } +} diff --git a/backend/src/main/java/com/festago/common/querydsl/QueryDslRepositorySupport.java b/backend/src/main/java/com/festago/common/querydsl/QueryDslRepositorySupport.java new file mode 100644 index 000000000..a969862cf --- /dev/null +++ b/backend/src/main/java/com/festago/common/querydsl/QueryDslRepositorySupport.java @@ -0,0 +1,90 @@ +package com.festago.common.querydsl; + +import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport; +import org.springframework.data.jpa.repository.support.Querydsl; +import org.springframework.data.querydsl.SimpleEntityPathResolver; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.Assert; + +public abstract class QueryDslRepositorySupport { + + private static final int NEXT_PAGE_OFFSET = 1; + private final Class domainClass; + private Querydsl querydsl; + private JPAQueryFactory queryFactory; + + protected QueryDslRepositorySupport(Class domainClass) { + this.domainClass = domainClass; + } + + @Autowired + protected void setQueryFactory(EntityManager entityManager) { + Assert.notNull(entityManager, "EntityManager must not be null!"); + JpaEntityInformation entityInformation = + JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager); + SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE; + EntityPath path = resolver.createPath(entityInformation.getJavaType()); + this.querydsl = new Querydsl(entityManager, new PathBuilder<>(path.getType(), path.getMetadata())); + this.queryFactory = new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); + } + + protected JPAQuery select(Expression expr) { + return queryFactory.select(expr); + } + + protected JPAQuery selectFrom(EntityPath expr) { + return queryFactory.selectFrom(expr); + } + + protected Optional fetchOne( + Function> queryFunction + ) { + JPAQuery query = queryFunction.apply(queryFactory); + return Optional.ofNullable(query.fetchOne()); + } + + protected Page applyPagination( + Pageable pageable, + Function> contentQueryFunction, + Function> countQueryFunction + ) { + JPAQuery contentQuery = contentQueryFunction.apply(queryFactory); + List content = querydsl.applyPagination(pageable, contentQuery).fetch(); + JPAQuery countQuery = countQueryFunction.apply(queryFactory); + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + protected Slice applySlice( + Pageable pageable, + Function> contentQueryFunction + ) { + List content = contentQueryFunction.apply(queryFactory) + .limit(pageable.getPageSize() + (long) NEXT_PAGE_OFFSET) + .fetch(); + if (content.size() > pageable.getPageSize()) { + removeTemporaryContent(content); + return new SliceImpl<>(content, pageable, true); + } + return new SliceImpl<>(content, pageable, false); + } + + private void removeTemporaryContent(List content) { + content.remove(content.size() - NEXT_PAGE_OFFSET); + } +} diff --git a/backend/src/main/java/com/festago/common/querydsl/SearchCondition.java b/backend/src/main/java/com/festago/common/querydsl/SearchCondition.java new file mode 100644 index 000000000..9f7e73fc5 --- /dev/null +++ b/backend/src/main/java/com/festago/common/querydsl/SearchCondition.java @@ -0,0 +1,28 @@ +package com.festago.common.querydsl; + +import com.festago.common.util.Validator; +import jakarta.annotation.Nonnull; +import org.springframework.data.domain.Pageable; + +public record SearchCondition( + String searchFilter, + String searchKeyword, + @Nonnull Pageable pageable +) { + + public SearchCondition { + Validator.notNull(pageable, "pageable"); + } + + @Nonnull + @Override + public String searchFilter() { + return searchFilter != null ? searchFilter : ""; + } + + @Nonnull + @Override + public String searchKeyword() { + return searchKeyword != null ? searchKeyword : ""; + } +} diff --git a/backend/src/main/java/com/festago/common/util/ImageUrlHelper.java b/backend/src/main/java/com/festago/common/util/ImageUrlHelper.java new file mode 100644 index 000000000..f1d29d783 --- /dev/null +++ b/backend/src/main/java/com/festago/common/util/ImageUrlHelper.java @@ -0,0 +1,17 @@ +package com.festago.common.util; + +import org.springframework.util.StringUtils; + +public class ImageUrlHelper { + + private ImageUrlHelper() { + + } + + public static String getBlankStringIfBlank(String input) { + if (StringUtils.hasText(input)) { + return input; + } + return ""; + } +} diff --git a/backend/src/main/java/com/festago/common/util/Validator.java b/backend/src/main/java/com/festago/common/util/Validator.java index 481a29730..114699a24 100644 --- a/backend/src/main/java/com/festago/common/util/Validator.java +++ b/backend/src/main/java/com/festago/common/util/Validator.java @@ -1,49 +1,217 @@ package com.festago.common.util; +import com.festago.common.exception.UnexpectedException; +import com.festago.common.exception.ValidException; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; + public final class Validator { private Validator() { } /** - * 문자열의 최대 길이를 검증합니다. null 값은 무시됩니다. 최대 길이가 0 이하이면 예외를 던집니다. 문자열의 길이가 maxLength보다 작거나 같으면 예외를 던지지 않습니다. + * 문자열이 null 또는 공백인지 검사합니다. + * + * @param input 검증할 문자열 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws ValidException input이 null 또는 공백이면 + */ + public static void notBlank(String input, String fieldName) { + if (input == null || input.isBlank()) { + throw new ValidException("%s은/는 null 또는 공백이 될 수 없습니다.".formatted(fieldName)); + } + } + + /** + * 문자열의 최대 길이를 검증합니다. null 값은 무시됩니다. 최대 길이가 0 이하이면 예외를 던집니다. 문자열의 길이가 maxLength 이하이면 예외를 던지지 않습니다. * * @param input 검증할 문자열 * @param maxLength 검증할 문자열의 최대 길이 - * @param message 예외 메시지 - * @throws IllegalArgumentException 문자열의 길이가 초과되거나, 최대 길이가 0 이하이면 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws UnexpectedException 최대 길이가 0 이하이면 + * @throws ValidException input의 길이가 maxLength 초과하면 */ - public static void maxLength(CharSequence input, int maxLength, String message) { + public static void maxLength(CharSequence input, int maxLength, String fieldName) { if (maxLength <= 0) { - throw new IllegalArgumentException("검증 길이는 0보다 커야합니다."); + throw new UnexpectedException("최대 길이는 0 이하일 수 없습니다."); } // avoid NPE if (input == null) { return; } if (input.length() > maxLength) { - throw new IllegalArgumentException(message); + throw new ValidException("%s의 길이는 %d글자 이하여야 합니다.".formatted(fieldName, maxLength)); } } /** - * 문자열의 최소 길이를 검증합니다. null 값은 무시됩니다. 최소 길이가 0 이하이면 예외를 던집니다. 문자열의 길이가 minLength보다 크거나 같으면 예외를 던지지 않습니다. + * 문자열의 최소 길이를 검증합니다. null 값은 무시됩니다. 최소 길이가 0 이하이면 예외를 던집니다. 문자열의 길이가 minLength보다 이상이면 예외를 던지지 않습니다. * * @param input 검증할 문자열 * @param minLength 검증할 문자열의 최소 길이 - * @param message 예외 메시지 - * @throws IllegalArgumentException 문자열의 길이가 작으면, 최대 길이가 0 이하이면 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws UnexpectedException maxLength가 0 이하이면 + * @throws ValidException input의 길이가 minLength 미만이면 */ - public static void minLength(CharSequence input, int minLength, String message) { + public static void minLength(CharSequence input, int minLength, String fieldName) { if (minLength <= 0) { - throw new IllegalArgumentException("검증 길이는 0보다 커야합니다."); + throw new UnexpectedException("최소 길이는 0 이하일 수 없습니다."); } // avoid NPE if (input == null) { return; } if (input.length() < minLength) { - throw new IllegalArgumentException(message); + throw new ValidException("%s의 길이는 %d글자 이상이어야 합니다.".formatted(fieldName, minLength)); + } + } + + /** + * 객체가 null인지 검사합니다. + * + * @param object 검증할 객체 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws ValidException object가 null 이면 + */ + public static void notNull(Object object, String fieldName) { + if (object == null) { + throw new ValidException("%s은/는 null이 될 수 없습니다.".formatted(fieldName)); + } + } + + /** + * 값의 최대 값을 검증합니다. + * + * @param value 검증할 값 + * @param maxValue 검증할 값의 최대 값 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws ValidException value가 maxValue 초과하면 + */ + public static void maxValue(int value, int maxValue, String fieldName) { + if (value > maxValue) { + throw new ValidException("%s은/는 %d 이하여야 합니다.".formatted(fieldName, maxValue)); + } + } + + /** + * 값의 최대 값을 검증합니다. + * + * @param value 검증할 값 + * @param maxValue 검증할 값의 최대 값 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws ValidException value가 maxValue 초과하면 + */ + public static void maxValue(long value, long maxValue, String fieldName) { + if (value > maxValue) { + throw new ValidException("%s은/는 %d 이하여야 합니다.".formatted(fieldName, maxValue)); + } + } + + /** + * 값의 최소 값을 검증합니다. + * + * @param value 검증할 값 + * @param minValue 검증할 값의 최소 값 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws ValidException value가 minValue 미만이면 + */ + public static void minValue(int value, int minValue, String fieldName) { + if (value < minValue) { + throw new ValidException("%s은/는 %d 이상이어야 합니다.".formatted(fieldName, minValue)); + } + } + + /** + * 값의 최소 값을 검증합니다. + * + * @param value 검증할 값 + * @param minValue 검증할 값의 최소 값 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws ValidException value가 minValue 미만이면 + */ + public static void minValue(long value, long minValue, String fieldName) { + if (value < minValue) { + throw new ValidException("%s은/는 %d 이상이어야 합니다.".formatted(fieldName, minValue)); + } + } + + /** + * 값이 음수인지 검증합니다. + * + * @param value 검증할 값 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws ValidException value가 음수이면 + */ + public static void notNegative(int value, String fieldName) { + if (value < 0) { + throw new ValidException("%s은/는 음수가 될 수 없습니다.".formatted(fieldName)); + } + } + + /** + * 값이 음수인지 검증합니다. + * + * @param value 검증할 값 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws ValidException value가 음수이면 + */ + public static void notNegative(long value, String fieldName) { + if (value < 0) { + throw new ValidException("%s은/는 음수가 될 수 없습니다.".formatted(fieldName)); + } + } + + /** + * 컬렉션의 최대 size를 검증합니다. + * + * @param collection 검증할 컬렉션 + * @param maxSize 검증할 컬렉션의 최대 원소 수 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws UnexpectedException 최대 크기가 0 이하이면 + * @throws ValidException collecion의 size가 maxSize를 초과하면 + */ + public static void maxSize(Collection collection, int maxSize, String fieldName) { + if (maxSize <= 0) { + throw new UnexpectedException("최대 size는 0 이하일 수 없습니다."); + } + if (collection.size() > maxSize) { + throw new ValidException("%s의 size는 %d 이하여야 합니다.".formatted(fieldName, maxSize)); + } + } + + /** + * 컬렉션의 최소 size를 검증합니다. + * + * @param collection 검증할 컬렉션 + * @param minSize 검증할 컬렉션의 최소 원소 수 + * @param fieldName 예외 메시지에 출력할 필드명 + * @throws UnexpectedException 최대 크기가 0 이하이면 + * @throws ValidException collecion의 size가 minSize 미만이면 + */ + public static void minSize(Collection collection, int minSize, String fieldName) { + if (minSize <= 0) { + throw new UnexpectedException("최대 size는 0 이하일 수 없습니다."); + } + if (collection.size() < minSize) { + throw new ValidException("%s의 size는 %d 이상이어야 합니다.".formatted(fieldName, minSize)); + } + } + + /** + * 리스트에 중복이 있는지 검사합니다. HashSet을 사용하여 중복을 검사하므로, 리스트의 원소 타입은 반드시 equals, hashCode 메서드를 구현해야 합니다. + * + * @param list 검증할 리스트 + * @param fieldName 예외 메시지에 출력할 필드명 + */ + public static void notDuplicate(List list, String fieldName) { + // avoid NPE + if (list == null || list.isEmpty()) { + return; + } + if (new HashSet<>(list).size() != list.size()) { + throw new ValidException("%s에 중복된 값이 있습니다.".formatted(fieldName)); } } } diff --git a/backend/src/main/java/com/festago/config/ErrorLoggerConfig.java b/backend/src/main/java/com/festago/config/ErrorLoggerConfig.java deleted file mode 100644 index d0ec785df..000000000 --- a/backend/src/main/java/com/festago/config/ErrorLoggerConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.festago.config; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class ErrorLoggerConfig { - - @Bean - public Logger errorLogger() { - return LoggerFactory.getLogger("ErrorLogger"); - } -} diff --git a/backend/src/main/java/com/festago/config/QuerydslConfig.java b/backend/src/main/java/com/festago/config/QuerydslConfig.java new file mode 100644 index 000000000..8dabf4a66 --- /dev/null +++ b/backend/src/main/java/com/festago/config/QuerydslConfig.java @@ -0,0 +1,20 @@ +package com.festago.config; + +import com.querydsl.jpa.JPQLTemplates; +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(JPQLTemplates.DEFAULT, entityManager); + } +} diff --git a/backend/src/main/java/com/festago/config/RequestWrappingFilter.java b/backend/src/main/java/com/festago/config/RequestWrappingFilter.java deleted file mode 100644 index dba5f7d6e..000000000 --- a/backend/src/main/java/com/festago/config/RequestWrappingFilter.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.festago.config; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.util.ContentCachingRequestWrapper; - -@Component -public class RequestWrappingFilter extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, FilterChain chain) - throws ServletException, IOException { - - ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request); - - chain.doFilter(wrappingRequest, response); - } -} diff --git a/backend/src/main/java/com/festago/config/SchedulerConfig.java b/backend/src/main/java/com/festago/config/SchedulerConfig.java new file mode 100644 index 000000000..3a496b0d8 --- /dev/null +++ b/backend/src/main/java/com/festago/config/SchedulerConfig.java @@ -0,0 +1,10 @@ +package com.festago.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@Configuration +public class SchedulerConfig { + +} diff --git a/backend/src/main/java/com/festago/config/WebConfig.java b/backend/src/main/java/com/festago/config/WebConfig.java new file mode 100644 index 000000000..5c85d6d1c --- /dev/null +++ b/backend/src/main/java/com/festago/config/WebConfig.java @@ -0,0 +1,35 @@ +package com.festago.config; + +import java.util.Locale; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.FixedLocaleResolver; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final String allowOrigins; + + public WebConfig(@Value("${festago.cors-allow-origins}") String allowOrigins) { + this.allowOrigins = allowOrigins; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns(allowOrigins) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + + @Bean + public LocaleResolver localeResolver() { + return new FixedLocaleResolver(Locale.KOREA); + } +} diff --git a/backend/src/main/java/com/festago/entry/domain/EntryCode.java b/backend/src/main/java/com/festago/entry/domain/EntryCode.java index c75fde867..98da55c23 100644 --- a/backend/src/main/java/com/festago/entry/domain/EntryCode.java +++ b/backend/src/main/java/com/festago/entry/domain/EntryCode.java @@ -1,7 +1,7 @@ package com.festago.entry.domain; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.UnexpectedException; +import org.springframework.util.StringUtils; public class EntryCode { @@ -13,18 +13,21 @@ public class EntryCode { private final long offset; public EntryCode(String code, long period, long offset) { - validate(period, offset); + validate(code, period, offset); this.code = code; this.period = period; this.offset = offset; } - private void validate(long period, long offset) { + private void validate(String code, long period, long offset) { + if (!StringUtils.hasText(code)) { + throw new UnexpectedException("code는 빈 값 또는 null이 될 수 없습니다."); + } if (period <= MINIMUM_PERIOD) { - throw new InternalServerException(ErrorCode.INVALID_ENTRY_CODE_PERIOD); + throw new UnexpectedException("period는 0 또는 음수가 될 수 없습니다."); } if (isNegative(offset)) { - throw new InternalServerException(ErrorCode.INVALID_ENTRY_CODE_OFFSET); + throw new UnexpectedException("offset은 음수가 될 수 없습니다."); } } diff --git a/backend/src/main/java/com/festago/entry/domain/EntryCodePayload.java b/backend/src/main/java/com/festago/entry/domain/EntryCodePayload.java index 38ff7c411..16762a93d 100644 --- a/backend/src/main/java/com/festago/entry/domain/EntryCodePayload.java +++ b/backend/src/main/java/com/festago/entry/domain/EntryCodePayload.java @@ -1,7 +1,6 @@ package com.festago.entry.domain; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.UnexpectedException; import com.festago.ticketing.domain.EntryState; import com.festago.ticketing.domain.MemberTicket; @@ -16,16 +15,19 @@ public EntryCodePayload(Long memberTicketId, EntryState entryState) { this.entryState = entryState; } - public static EntryCodePayload from(MemberTicket memberTicket) { - return new EntryCodePayload(memberTicket.getId(), memberTicket.getEntryState()); - } - private void validate(Long memberTicketId, EntryState entryState) { - if (memberTicketId == null || entryState == null) { - throw new InternalServerException(ErrorCode.INVALID_ENTRY_CODE_PAYLOAD); + if (memberTicketId == null) { + throw new UnexpectedException("memberTicketId는 null이 될 수 없습니다."); + } + if (entryState == null) { + throw new UnexpectedException("entryState는 null이 될 수 없습니다."); } } + public static EntryCodePayload from(MemberTicket memberTicket) { + return new EntryCodePayload(memberTicket.getId(), memberTicket.getEntryState()); + } + public Long getMemberTicketId() { return memberTicketId; } diff --git a/backend/src/main/java/com/festago/entry/infrastructure/JwtEntryCodeExtractor.java b/backend/src/main/java/com/festago/entry/infrastructure/JwtEntryCodeExtractor.java index dbc0bf63c..a9a723650 100644 --- a/backend/src/main/java/com/festago/entry/infrastructure/JwtEntryCodeExtractor.java +++ b/backend/src/main/java/com/festago/entry/infrastructure/JwtEntryCodeExtractor.java @@ -7,13 +7,17 @@ import com.festago.ticketing.domain.EntryState; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; import java.nio.charset.StandardCharsets; import javax.crypto.SecretKey; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class JwtEntryCodeExtractor implements EntryCodeExtractor { private static final String MEMBER_TICKET_ID_KEY = "ticketId"; @@ -23,8 +27,8 @@ public class JwtEntryCodeExtractor implements EntryCodeExtractor { public JwtEntryCodeExtractor(String secretKey) { SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); - this.jwtParser = Jwts.parserBuilder() - .setSigningKey(key) + this.jwtParser = Jwts.parser() + .verifyWith(key) .build(); } @@ -39,12 +43,15 @@ public EntryCodePayload extract(String code) { private Claims getClaims(String code) { try { - return jwtParser.parseClaimsJws(code) - .getBody(); + return jwtParser.parseSignedClaims(code) + .getPayload(); } catch (ExpiredJwtException e) { throw new BadRequestException(ErrorCode.EXPIRED_ENTRY_CODE); - } catch (JwtException | IllegalArgumentException e) { + } catch (SignatureException | IllegalArgumentException | MalformedJwtException | UnsupportedJwtException e) { throw new BadRequestException(ErrorCode.INVALID_ENTRY_CODE); + } catch (Exception e) { + log.error("JWT 토큰 파싱 중에 문제가 발생했습니다."); + throw e; } } } diff --git a/backend/src/main/java/com/festago/entry/infrastructure/JwtEntryCodeProvider.java b/backend/src/main/java/com/festago/entry/infrastructure/JwtEntryCodeProvider.java index 852915e6c..c1119102b 100644 --- a/backend/src/main/java/com/festago/entry/infrastructure/JwtEntryCodeProvider.java +++ b/backend/src/main/java/com/festago/entry/infrastructure/JwtEntryCodeProvider.java @@ -1,11 +1,9 @@ package com.festago.entry.infrastructure; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.UnexpectedException; import com.festago.entry.application.EntryCodeProvider; import com.festago.entry.domain.EntryCodePayload; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; import java.util.Date; @@ -29,14 +27,14 @@ public String provide(EntryCodePayload entryCodePayload, Date expiredAt) { return Jwts.builder() .claim(MEMBER_TICKET_ID_KEY, entryCodePayload.getMemberTicketId()) .claim(ENTRY_STATE_KEY, entryCodePayload.getEntryState().getIndex()) - .setExpiration(expiredAt) - .signWith(key, SignatureAlgorithm.HS256) + .expiration(expiredAt) + .signWith(key) .compact(); } private void validate(Date expiredAt) { if (expiredAt.before(new Date())) { - throw new InternalServerException(ErrorCode.INVALID_ENTRY_CODE_EXPIRATION_TIME); + throw new UnexpectedException("입장코드 만료일자는 과거일 수 없습니다."); } } } diff --git a/backend/src/main/java/com/festago/entry/presentation/MemberEntranceController.java b/backend/src/main/java/com/festago/entry/presentation/MemberEntranceController.java new file mode 100644 index 000000000..1fb3e7bd1 --- /dev/null +++ b/backend/src/main/java/com/festago/entry/presentation/MemberEntranceController.java @@ -0,0 +1,31 @@ +package com.festago.entry.presentation; + +import com.festago.auth.annotation.Member; +import com.festago.entry.application.EntryService; +import com.festago.entry.dto.EntryCodeResponse; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@Deprecated(forRemoval = true) +@Hidden +@RestController +@RequiredArgsConstructor +public class MemberEntranceController { + + private final EntryService entryService; + + // TODO: URI 다시 생각해보기 + @PostMapping("/member-tickets/{memberTicketId}/qr") + @Operation(description = "티켓 제시용 QR 코드를 생성한다.", summary = "티켓 제시용 QR 생성") + public ResponseEntity createQR(@Member Long memberId, + @PathVariable Long memberTicketId) { + EntryCodeResponse response = entryService.createEntryCode(memberId, memberTicketId); + return ResponseEntity.ok() + .body(response); + } +} diff --git a/backend/src/main/java/com/festago/presentation/StaffMemberTicketController.java b/backend/src/main/java/com/festago/entry/presentation/StaffMemberTicketController.java similarity index 90% rename from backend/src/main/java/com/festago/presentation/StaffMemberTicketController.java rename to backend/src/main/java/com/festago/entry/presentation/StaffMemberTicketController.java index 4c05c3e42..362412936 100644 --- a/backend/src/main/java/com/festago/presentation/StaffMemberTicketController.java +++ b/backend/src/main/java/com/festago/entry/presentation/StaffMemberTicketController.java @@ -1,9 +1,10 @@ -package com.festago.presentation; +package com.festago.entry.presentation; import com.festago.entry.application.EntryService; import com.festago.entry.dto.TicketValidationRequest; import com.festago.entry.dto.TicketValidationResponse; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -14,6 +15,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Deprecated(forRemoval = true) +@Hidden @RestController @RequestMapping("/staff/member-tickets") @Tag(name = "스태프 요청") diff --git a/backend/src/main/java/com/festago/fcm/application/FCMMemberDeleteEventListener.java b/backend/src/main/java/com/festago/fcm/application/FCMMemberDeleteEventListener.java new file mode 100644 index 000000000..c85be9e4d --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/application/FCMMemberDeleteEventListener.java @@ -0,0 +1,18 @@ +package com.festago.fcm.application; + +import com.festago.auth.dto.event.MemberDeletedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FCMMemberDeleteEventListener { + + private final MemberFCMService memberFCMService; + + @EventListener + public void memberDeleteEventHandler(MemberDeletedEvent event) { + memberFCMService.deleteAllMemberFCM(event.member().getId()); + } +} diff --git a/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java b/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java index 92f369e34..3169d8cc1 100644 --- a/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java +++ b/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java @@ -2,7 +2,6 @@ import com.festago.entry.dto.event.EntryProcessEvent; import com.festago.fcm.domain.FCMChannel; -import com.festago.fcm.dto.MemberFCMResponse; import com.google.firebase.messaging.AndroidConfig; import com.google.firebase.messaging.AndroidNotification; import com.google.firebase.messaging.BatchResponse; @@ -11,8 +10,7 @@ import com.google.firebase.messaging.Message; import com.google.firebase.messaging.SendResponse; import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -21,10 +19,9 @@ @Component @Profile("prod | dev") +@Slf4j public class FCMNotificationEventListener { - private static final Logger log = LoggerFactory.getLogger(FCMNotificationEventListener.class); - private final FirebaseMessaging firebaseMessaging; private final MemberFCMService memberFCMService; @@ -36,7 +33,8 @@ public FCMNotificationEventListener(FirebaseMessaging firebaseMessaging, MemberF @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async public void sendFcmNotification(EntryProcessEvent event) { - List messages = createMessages(getMemberFCMToken(event.memberId()), FCMChannel.ENTRY_PROCESS.name()); + List memberFCMTokens = getMemberFCMTokens(event.memberId()); + List messages = createMessages(memberFCMTokens, FCMChannel.ENTRY_PROCESS.name()); try { BatchResponse batchResponse = firebaseMessaging.sendAll(messages); checkAllSuccess(batchResponse, event.memberId()); @@ -45,10 +43,8 @@ public void sendFcmNotification(EntryProcessEvent event) { } } - private List getMemberFCMToken(Long memberId) { - return memberFCMService.findMemberFCM(memberId).memberFCMs().stream() - .map(MemberFCMResponse::fcmToken) - .toList(); + private List getMemberFCMTokens(Long memberId) { + return memberFCMService.findAllMemberFCMTokens(memberId); } private List createMessages(List tokens, String channelId) { diff --git a/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java b/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java index ad346b2d6..9c6a268a7 100644 --- a/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java +++ b/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java @@ -1,70 +1,52 @@ package com.festago.fcm.application; -import com.festago.auth.application.AuthExtractor; -import com.festago.auth.domain.AuthPayload; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; import com.festago.fcm.domain.MemberFCM; -import com.festago.fcm.dto.MemberFCMsResponse; import com.festago.fcm.repository.MemberFCMRepository; +import com.festago.member.repository.MemberRepository; +import java.util.Collections; import java.util.List; -import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Async; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @Transactional +@RequiredArgsConstructor public class MemberFCMService { - private static final Logger log = LoggerFactory.getLogger(MemberFCMService.class); - private final MemberFCMRepository memberFCMRepository; - private final AuthExtractor authExtractor; - - public MemberFCMService(MemberFCMRepository memberFCMRepository, AuthExtractor authExtractor) { - this.memberFCMRepository = memberFCMRepository; - this.authExtractor = authExtractor; - } + private final MemberRepository memberRepository; @Transactional(readOnly = true) - public MemberFCMsResponse findMemberFCM(Long memberId) { - List memberFCM = memberFCMRepository.findByMemberId(memberId); + public List findAllMemberFCMTokens(Long memberId) { + List memberFCM = memberFCMRepository.findAllByMemberId(memberId); if (memberFCM.isEmpty()) { log.warn("member {} 의 FCM 토큰이 발급되지 않았습니다.", memberId); + return Collections.emptyList(); } - return MemberFCMsResponse.from(memberFCM); + return memberFCM.stream() + .map(MemberFCM::getFcmToken) + .toList(); } - @Async - public void saveMemberFCM(boolean isNewMember, String accessToken, String fcmToken) { - if (isNewMember) { - saveNewMemberFCM(accessToken, fcmToken); - return; + public void saveMemberFCM(Long memberId, String fcmToken) { + if (!memberRepository.existsById(memberId)) { + throw new NotFoundException(ErrorCode.MEMBER_NOT_FOUND); } - saveOriginMemberFCM(accessToken, fcmToken); - } - - private void saveOriginMemberFCM(String accessToken, String fcmToken) { - Long memberId = extractMemberId(accessToken); - Optional memberFCM = memberFCMRepository.findMemberFCMByMemberIdAndFcmToken(memberId, fcmToken); - if (memberFCM.isEmpty()) { + if (isNotExistsFcmToken(memberId, fcmToken)) { memberFCMRepository.save(new MemberFCM(memberId, fcmToken)); } } - private Long extractMemberId(String accessToken) { - AuthPayload authPayload = authExtractor.extract(accessToken); - return authPayload.getMemberId(); - } - - private void saveNewMemberFCM(String accessToken, String fcmToken) { - Long memberId = extractMemberId(accessToken); - memberFCMRepository.save(new MemberFCM(memberId, fcmToken)); + private boolean isNotExistsFcmToken(Long memberId, String fcmToken) { + return !memberFCMRepository.existsByMemberIdAndFcmToken(memberId, fcmToken); } - @Async - public void deleteMemberFCM(Long memberId) { + public void deleteAllMemberFCM(Long memberId) { memberFCMRepository.deleteAllByMemberId(memberId); } } diff --git a/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java b/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java index 8a97f7f07..50131df62 100644 --- a/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java +++ b/backend/src/main/java/com/festago/fcm/domain/MemberFCM.java @@ -1,25 +1,43 @@ package com.festago.fcm.domain; import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.util.Validator; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; @Entity -@Table(name = "member_fcm") +@Table( + name = "member_fcm", + uniqueConstraints = { + @UniqueConstraint( + columnNames = { + "member_id", + "fcm_token" + } + ) + }) public class MemberFCM extends BaseTimeEntity { + private static final int MAX_FCM_TOKEN_LENGTH = 255; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotNull + @Column(name = "member_id") private Long memberId; @NotNull + @Size(max = MAX_FCM_TOKEN_LENGTH) + @Column(name = "fcm_token") private String fcmToken; protected MemberFCM() { @@ -37,9 +55,18 @@ public MemberFCM(Long id, Long memberId, String fcmToken) { } private void validate(Long memberId, String fcmToken) { - if (memberId == null || fcmToken == null) { - throw new IllegalArgumentException("MemberFCM 은 허용되지 않은 null 값으로 생성할 수 없습니다."); - } + validateMemberId(memberId); + validateFcmToken(fcmToken); + } + + private void validateMemberId(Long memberId) { + Validator.notNull(memberId, "memberId"); + } + + private void validateFcmToken(String fcmToken) { + String fieldName = "fcmToken"; + Validator.notBlank(fcmToken, fieldName); + Validator.maxLength(fcmToken, MAX_FCM_TOKEN_LENGTH, fieldName); } public Long getId() { diff --git a/backend/src/main/java/com/festago/fcm/dto/MemberFCMResponse.java b/backend/src/main/java/com/festago/fcm/dto/MemberFCMResponse.java deleted file mode 100644 index 61f252a9b..000000000 --- a/backend/src/main/java/com/festago/fcm/dto/MemberFCMResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.festago.fcm.dto; - -import com.festago.fcm.domain.MemberFCM; - -public record MemberFCMResponse( - Long id, - Long memberId, - String fcmToken -) { - - public static MemberFCMResponse from(MemberFCM memberFCM) { - return new MemberFCMResponse( - memberFCM.getId(), - memberFCM.getMemberId(), - memberFCM.getFcmToken() - ); - } -} diff --git a/backend/src/main/java/com/festago/fcm/dto/MemberFCMsResponse.java b/backend/src/main/java/com/festago/fcm/dto/MemberFCMsResponse.java deleted file mode 100644 index 80df8a602..000000000 --- a/backend/src/main/java/com/festago/fcm/dto/MemberFCMsResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.festago.fcm.dto; - -import static java.util.stream.Collectors.collectingAndThen; -import static java.util.stream.Collectors.toList; - -import com.festago.fcm.domain.MemberFCM; -import java.util.List; - -public record MemberFCMsResponse(List memberFCMs -) { - - public static MemberFCMsResponse from(List memberFCMs) { - return memberFCMs.stream() - .map(MemberFCMResponse::from) - .collect(collectingAndThen(toList(), MemberFCMsResponse::new)); - } -} diff --git a/backend/src/main/java/com/festago/fcm/dto/MemberFcmCreateRequest.java b/backend/src/main/java/com/festago/fcm/dto/MemberFcmCreateRequest.java new file mode 100644 index 000000000..975b39cd4 --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/dto/MemberFcmCreateRequest.java @@ -0,0 +1,10 @@ +package com.festago.fcm.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MemberFcmCreateRequest( + @NotBlank(message = "fcmToken은 공백일 수 없습니다.") + String fcmToken +) { + +} diff --git a/backend/src/main/java/com/festago/fcm/presentation/MemberFCMController.java b/backend/src/main/java/com/festago/fcm/presentation/MemberFCMController.java new file mode 100644 index 000000000..dd10f5107 --- /dev/null +++ b/backend/src/main/java/com/festago/fcm/presentation/MemberFCMController.java @@ -0,0 +1,36 @@ +package com.festago.fcm.presentation; + +import com.festago.auth.annotation.Member; +import com.festago.fcm.application.MemberFCMService; +import com.festago.fcm.dto.MemberFcmCreateRequest; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +@Deprecated(forRemoval = true) +@Hidden +@RestController +@RequiredArgsConstructor +@Tag(name = "유저 FCM 정보 요청") +@RequestMapping("/member-fcm") +@SecurityRequirement(name = "bearerAuth") +public class MemberFCMController { + + private final MemberFCMService memberFCMService; + + @PostMapping + @Operation(description = "유저의 FCM 토큰을 등록한다.", summary = "FCM 토큰 등록") + public ResponseEntity createMemberFcm(@Member Long memberId, + @RequestBody MemberFcmCreateRequest request) { + memberFCMService.saveMemberFCM(memberId, request.fcmToken()); + return ResponseEntity.ok() + .build(); + } +} diff --git a/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java b/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java index fb6ed5709..e8012ed73 100644 --- a/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java +++ b/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java @@ -2,14 +2,13 @@ import com.festago.fcm.domain.MemberFCM; import java.util.List; -import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberFCMRepository extends JpaRepository { - List findByMemberId(Long memberId); + List findAllByMemberId(Long memberId); - Optional findMemberFCMByMemberIdAndFcmToken(Long memberId, String fcmToken); + boolean existsByMemberIdAndFcmToken(Long memberId, String fcmToken); void deleteAllByMemberId(Long memberId); } diff --git a/backend/src/main/java/com/festago/festival/application/FestivalDetailV1QueryService.java b/backend/src/main/java/com/festago/festival/application/FestivalDetailV1QueryService.java new file mode 100644 index 000000000..73c82ac0e --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/FestivalDetailV1QueryService.java @@ -0,0 +1,22 @@ +package com.festago.festival.application; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.dto.FestivalDetailV1Response; +import com.festago.festival.repository.FestivalDetailV1QueryDslRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FestivalDetailV1QueryService { + + private final FestivalDetailV1QueryDslRepository festivalDetailV1QueryDslRepository; + + public FestivalDetailV1Response findFestivalDetail(Long festivalId) { + return festivalDetailV1QueryDslRepository.findFestivalDetail(festivalId) + .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/FestivalQueryInfoArtistRenewService.java b/backend/src/main/java/com/festago/festival/application/FestivalQueryInfoArtistRenewService.java new file mode 100644 index 000000000..4a85900bd --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/FestivalQueryInfoArtistRenewService.java @@ -0,0 +1,30 @@ +package com.festago.festival.application; + +import com.festago.artist.domain.Artist; +import com.festago.artist.domain.ArtistsSerializer; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.FestivalIdStageArtistsResolver; +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.festival.repository.FestivalInfoRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FestivalQueryInfoArtistRenewService { + + private final FestivalInfoRepository festivalInfoRepository; + private final FestivalIdStageArtistsResolver festivalIdStageArtistsResolver; + private final ArtistsSerializer serializer; + + public void renewArtistInfo(Long festivalId) { + FestivalQueryInfo festivalQueryInfo = festivalInfoRepository.findByFestivalId(festivalId) + .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); + List artists = festivalIdStageArtistsResolver.resolve(festivalId); + festivalQueryInfo.updateArtistInfo(artists, serializer); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/FestivalQueryInfoEventListener.java b/backend/src/main/java/com/festago/festival/application/FestivalQueryInfoEventListener.java new file mode 100644 index 000000000..ef9bbe0bb --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/FestivalQueryInfoEventListener.java @@ -0,0 +1,37 @@ +package com.festago.festival.application; + +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.festival.dto.event.FestivalCreatedEvent; +import com.festago.festival.dto.event.FestivalDeletedEvent; +import com.festago.festival.repository.FestivalInfoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class FestivalQueryInfoEventListener { + + private final FestivalInfoRepository festivalInfoRepository; + + /** + * 해당 이벤트는 비동기로 실행하면 문제가 발생할 수 있으니, 동기적으로 처리해야함
축제가 생성되면 FestivalQueryInfo는 반드시! 생성되어야 함 + */ + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void festivalCreatedEventHandler(FestivalCreatedEvent event) { + FestivalQueryInfo festivalQueryInfo = FestivalQueryInfo.create(event.festival().getId()); + festivalInfoRepository.save(festivalQueryInfo); + } + + /** + * 삭제의 경우 동기적으로 처리될 필요가 없음
하지만 일관성을 위해 동기적으로 처리함 + */ + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void festivalDeletedEventHandler(FestivalDeletedEvent event) { + festivalInfoRepository.deleteByFestivalId(event.festivalId()); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/FestivalSearchV1QueryService.java b/backend/src/main/java/com/festago/festival/application/FestivalSearchV1QueryService.java new file mode 100644 index 000000000..ab0ce0183 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/FestivalSearchV1QueryService.java @@ -0,0 +1,89 @@ +package com.festago.festival.application; + +import static com.festago.festival.repository.FestivalFilter.END; +import static com.festago.festival.repository.FestivalFilter.PLANNED; +import static com.festago.festival.repository.FestivalFilter.PROGRESS; + +import com.festago.festival.dto.FestivalSearchV1Response; +import com.festago.festival.repository.ArtistFestivalSearchV1QueryDslRepository; +import com.festago.festival.repository.FestivalFilter; +import com.festago.festival.repository.SchoolFestivalSearchV1QueryDslRepository; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FestivalSearchV1QueryService { + + private final ArtistFestivalSearchV1QueryDslRepository artistFestivalSearchV1QueryDslRepository; + private final SchoolFestivalSearchV1QueryDslRepository schoolFestivalSearchV1QueryDslRepository; + + public List search(String keyword) { + return sortFestival(findFestivals(keyword)); + } + + private List findFestivals(String keyword) { + if (artistFestivalSearchV1QueryDslRepository.existsByName(keyword)) { + return artistFestivalSearchV1QueryDslRepository.executeSearch(keyword); + } + return schoolFestivalSearchV1QueryDslRepository.executeSearch(keyword); + } + + private List sortFestival(List festivals) { + Map> festivalByStatus = divideByStatus(festivals); + List result = new ArrayList<>(); + for (FestivalFilter festivalFilter : determineFestivalOrder()) { + List festivalsByFilter = festivalByStatus.getOrDefault(festivalFilter, + Collections.emptyList()); + sortByStatus(festivalFilter, festivalsByFilter); + result.addAll(festivalsByFilter); + } + return result; + } + + private Map> divideByStatus( + List festivals + ) { + return festivals.stream() + .collect(Collectors.groupingBy( + this::determineStatus, + () -> new EnumMap<>(FestivalFilter.class), + Collectors.toList() + )); + } + + private FestivalFilter determineStatus(FestivalSearchV1Response festival) { + LocalDate now = LocalDate.now(); + if (now.isAfter(festival.endDate())) { + return END; + } + if (now.isBefore(festival.startDate())) { + return PLANNED; + } + return PROGRESS; + } + + private List determineFestivalOrder() { + return List.of(PROGRESS, PLANNED, END); + } + + private void sortByStatus( + FestivalFilter status, + List festivals) { + + switch (status) { + case END -> festivals.sort(Comparator.comparing(FestivalSearchV1Response::endDate).reversed()); + case PROGRESS, PLANNED -> festivals.sort(Comparator.comparing(FestivalSearchV1Response::startDate)); + } + } +} diff --git a/backend/src/main/java/com/festago/festival/application/FestivalService.java b/backend/src/main/java/com/festago/festival/application/FestivalService.java deleted file mode 100644 index 4fe7c285f..000000000 --- a/backend/src/main/java/com/festago/festival/application/FestivalService.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.festago.festival.application; - -import static java.util.Comparator.comparing; - -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.NotFoundException; -import com.festago.festival.domain.Festival; -import com.festago.festival.dto.FestivalCreateRequest; -import com.festago.festival.dto.FestivalDetailResponse; -import com.festago.festival.dto.FestivalResponse; -import com.festago.festival.dto.FestivalUpdateRequest; -import com.festago.festival.dto.FestivalsResponse; -import com.festago.festival.repository.FestivalRepository; -import com.festago.school.domain.School; -import com.festago.school.repository.SchoolRepository; -import com.festago.stage.domain.Stage; -import com.festago.stage.repository.StageRepository; -import java.time.Clock; -import java.time.LocalDate; -import java.util.List; -import org.springframework.dao.DataIntegrityViolationException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional -@RequiredArgsConstructor -public class FestivalService { - - private final FestivalRepository festivalRepository; - private final StageRepository stageRepository; - private final SchoolRepository schoolRepository; - private final Clock clock; - - public FestivalResponse create(FestivalCreateRequest request) { - School school = schoolRepository.findById(request.schoolId()) - .orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)); - Festival festival = request.toEntity(school); - validate(festival); - return FestivalResponse.from(festivalRepository.save(festival)); - } - - private void validate(Festival festival) { - if (!festival.canCreate(LocalDate.now(clock))) { - throw new BadRequestException(ErrorCode.INVALID_FESTIVAL_START_DATE); - } - } - - @Transactional(readOnly = true) - public FestivalsResponse findAll() { - List festivals = festivalRepository.findAll(); - return FestivalsResponse.from(festivals); - } - - @Transactional(readOnly = true) - public FestivalDetailResponse findDetail(Long festivalId) { - Festival festival = findFestival(festivalId); - List stages = stageRepository.findAllDetailByFestivalId(festivalId).stream() - .sorted(comparing(Stage::getStartTime)) - .toList(); - return FestivalDetailResponse.of(festival, stages); - } - - private Festival findFestival(Long festivalId) { - return festivalRepository.findById(festivalId) - .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); - } - - public void update(Long festivalId, FestivalUpdateRequest request) { - Festival festival = findFestival(festivalId); - festival.changeName(request.name()); - festival.changeThumbnail(request.thumbnail()); - festival.changeDate(request.startDate(), request.endDate()); - validate(festival); - } - - public void delete(Long festivalId) { - try { - festivalRepository.deleteById(festivalId); - festivalRepository.flush(); - } catch (DataIntegrityViolationException e) { - throw new BadRequestException(ErrorCode.DELETE_CONSTRAINT_FESTIVAL); - } - } -} diff --git a/backend/src/main/java/com/festago/festival/application/FestivalV1QueryService.java b/backend/src/main/java/com/festago/festival/application/FestivalV1QueryService.java new file mode 100644 index 000000000..8e376dadd --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/FestivalV1QueryService.java @@ -0,0 +1,34 @@ +package com.festago.festival.application; + +import com.festago.festival.dto.FestivalV1QueryRequest; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.repository.FestivalSearchCondition; +import com.festago.festival.repository.FestivalV1QueryDslRepository; +import java.time.Clock; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FestivalV1QueryService { + + private final FestivalV1QueryDslRepository festivalV1QueryDslRepository; + private final Clock clock; + + public Slice findFestivals(Pageable pageable, FestivalV1QueryRequest request) { + return festivalV1QueryDslRepository.findBy(new FestivalSearchCondition( + request.filter(), + request.location(), + request.lastStartDate(), + request.lastFestivalId(), + pageable, + LocalDate.now(clock) + )); + } +} + diff --git a/backend/src/main/java/com/festago/festival/application/PopularFestivalV1QueryService.java b/backend/src/main/java/com/festago/festival/application/PopularFestivalV1QueryService.java new file mode 100644 index 000000000..7860c3523 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/PopularFestivalV1QueryService.java @@ -0,0 +1,28 @@ +package com.festago.festival.application; + +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.dto.PopularFestivalsV1Response; +import com.festago.festival.repository.PopularFestivalV1QueryDslRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PopularFestivalV1QueryService { + + private final PopularFestivalV1QueryDslRepository popularFestivalRepository; + private final Clock clock; + + public PopularFestivalsV1Response findPopularFestivals() { + List popularFestivals = popularFestivalRepository.findPopularFestivals(LocalDate.now(clock)); + return new PopularFestivalsV1Response( + "요즘 뜨는 축제", + popularFestivals + ); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/QueryDslSchoolUpcomingFestivalStartDateV1QueryService.java b/backend/src/main/java/com/festago/festival/application/QueryDslSchoolUpcomingFestivalStartDateV1QueryService.java new file mode 100644 index 000000000..cb4bc5797 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/QueryDslSchoolUpcomingFestivalStartDateV1QueryService.java @@ -0,0 +1,32 @@ +package com.festago.festival.application; + +import static java.util.stream.Collectors.toUnmodifiableMap; + +import com.festago.festival.repository.RecentSchoolFestivalV1QueryDslRepository; +import com.festago.school.application.v1.SchoolUpcomingFestivalStartDateV1QueryService; +import com.festago.school.dto.v1.SchoolUpcomingFestivalStartDateV1Response; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QueryDslSchoolUpcomingFestivalStartDateV1QueryService implements + SchoolUpcomingFestivalStartDateV1QueryService { + + private final RecentSchoolFestivalV1QueryDslRepository recentSchoolFestivalV1QueryDslRepository; + private final Clock clock; + + @Override + public Map getSchoolIdToUpcomingFestivalStartDate(List schoolIds) { + return recentSchoolFestivalV1QueryDslRepository.findRecentSchoolFestivals(schoolIds, LocalDate.now(clock)) + .stream() + .collect(toUnmodifiableMap(SchoolUpcomingFestivalStartDateV1Response::schoolId, + SchoolUpcomingFestivalStartDateV1Response::startDate)); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/command/FestivalCommandFacadeService.java b/backend/src/main/java/com/festago/festival/application/command/FestivalCommandFacadeService.java new file mode 100644 index 000000000..c4ae3c303 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/command/FestivalCommandFacadeService.java @@ -0,0 +1,27 @@ +package com.festago.festival.application.command; + +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.festival.dto.command.FestivalUpdateCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FestivalCommandFacadeService { + + private final FestivalCreateService festivalCreateService; + private final FestivalUpdateService festivalUpdateService; + private final FestivalDeleteService festivalDeleteService; + + public Long createFestival(FestivalCreateCommand command) { + return festivalCreateService.createFestival(command); + } + + public void updateFestival(Long festivalId, FestivalUpdateCommand command) { + festivalUpdateService.updateFestival(festivalId, command); + } + + public void deleteFestival(Long festivalId) { + festivalDeleteService.deleteFestival(festivalId); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/command/FestivalCreateService.java b/backend/src/main/java/com/festago/festival/application/command/FestivalCreateService.java new file mode 100644 index 000000000..45e0255c4 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/command/FestivalCreateService.java @@ -0,0 +1,42 @@ +package com.festago.festival.application.command; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.festival.dto.event.FestivalCreatedEvent; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import java.time.Clock; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FestivalCreateService { + + private final FestivalRepository festivalRepository; + private final SchoolRepository schoolRepository; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + public Long createFestival(FestivalCreateCommand command) { + School school = schoolRepository.getOrThrow(command.schoolId()); + Festival festival = command.toEntity(school); + validate(festival); + festivalRepository.save(festival); + eventPublisher.publishEvent(new FestivalCreatedEvent(festival)); + return festival.getId(); + } + + private void validate(Festival festival) { + if (festival.isStartDateBeforeTo(LocalDate.now(clock))) { + throw new BadRequestException(ErrorCode.INVALID_FESTIVAL_START_DATE); + } + } +} diff --git a/backend/src/main/java/com/festago/festival/application/command/FestivalDeleteService.java b/backend/src/main/java/com/festago/festival/application/command/FestivalDeleteService.java new file mode 100644 index 000000000..f96ba4337 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/command/FestivalDeleteService.java @@ -0,0 +1,26 @@ +package com.festago.festival.application.command; + +import com.festago.festival.domain.validator.FestivalDeleteValidator; +import com.festago.festival.dto.event.FestivalDeletedEvent; +import com.festago.festival.repository.FestivalRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FestivalDeleteService { + + private final FestivalRepository festivalRepository; + private final List validators; + private final ApplicationEventPublisher eventPublisher; + + public void deleteFestival(Long festivalId) { + validators.forEach(validator -> validator.validate(festivalId)); + festivalRepository.deleteById(festivalId); + eventPublisher.publishEvent(new FestivalDeletedEvent(festivalId)); + } +} diff --git a/backend/src/main/java/com/festago/festival/application/command/FestivalUpdateService.java b/backend/src/main/java/com/festago/festival/application/command/FestivalUpdateService.java new file mode 100644 index 000000000..78704cd49 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/application/command/FestivalUpdateService.java @@ -0,0 +1,35 @@ +package com.festago.festival.application.command; + +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalDuration; +import com.festago.festival.domain.validator.FestivalUpdateValidator; +import com.festago.festival.dto.command.FestivalUpdateCommand; +import com.festago.festival.dto.event.FestivalUpdatedEvent; +import com.festago.festival.repository.FestivalRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FestivalUpdateService { + + private final FestivalRepository festivalRepository; + private final List validators; + private final ApplicationEventPublisher eventPublisher; + + /** + * 강제로 수정할 일이 필요할 수 있으므로, 시작일이 과거여도 예외를 발생하지 않음 + */ + public void updateFestival(Long festivalId, FestivalUpdateCommand command) { + Festival festival = festivalRepository.getOrThrow(festivalId); + festival.changeName(command.name()); + festival.changePosterImageUrl(command.posterImageUrl()); + festival.changeFestivalDuration(new FestivalDuration(command.startDate(), command.endDate())); + validators.forEach(validator -> validator.validate(festival)); + eventPublisher.publishEvent(new FestivalUpdatedEvent(festival)); + } +} diff --git a/backend/src/main/java/com/festago/festival/domain/Festival.java b/backend/src/main/java/com/festago/festival/domain/Festival.java index 9b0522c08..e7995390e 100644 --- a/backend/src/main/java/com/festago/festival/domain/Festival.java +++ b/backend/src/main/java/com/festago/festival/domain/Festival.java @@ -1,8 +1,10 @@ package com.festago.festival.domain; import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.util.ImageUrlHelper; import com.festago.common.util.Validator; import com.festago.school.domain.School; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -13,7 +15,6 @@ import jakarta.validation.constraints.Size; import java.time.LocalDate; import java.time.LocalDateTime; -import org.springframework.util.Assert; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -21,79 +22,68 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Festival extends BaseTimeEntity { - private static final String DEFAULT_THUMBNAIL = "https://picsum.photos/536/354"; + private static final int MAX_NAME_LENGTH = 50; + private static final int MAX_POSTER_IMAGE_URL_LENGTH = 255; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotNull - @Size(max = 50) + @Size(max = MAX_NAME_LENGTH) private String name; - @NotNull - private LocalDate startDate; - - @NotNull - private LocalDate endDate; + @Embedded + private FestivalDuration festivalDuration; @NotNull - @Size(max = 255) - private String thumbnail; + @Size(max = MAX_POSTER_IMAGE_URL_LENGTH) + private String posterImageUrl; @NotNull @ManyToOne(fetch = FetchType.LAZY) private School school; - public Festival(String name, LocalDate startDate, LocalDate endDate, School school) { - this(null, name, startDate, endDate, DEFAULT_THUMBNAIL, school); - } - - public Festival(String name, LocalDate startDate, LocalDate endDate, String thumbnail, School school) { - this(null, name, startDate, endDate, thumbnail, school); + public Festival(String name, FestivalDuration festivalDuration, String posterImageUrl, School school) { + this(null, name, festivalDuration, posterImageUrl, school); } - public Festival(Long id, String name, LocalDate startDate, LocalDate endDate, String thumbnail, School school) { - validate(name, startDate, endDate, thumbnail); + public Festival(Long id, String name, FestivalDuration festivalDuration, String posterImageUrl, School school) { + validateName(name); + validateFestivalDuration(festivalDuration); + validatePosterImageUrl(posterImageUrl); + validateSchool(school); this.id = id; this.name = name; - this.startDate = startDate; - this.endDate = endDate; - this.thumbnail = thumbnail; + this.festivalDuration = festivalDuration; + this.posterImageUrl = ImageUrlHelper.getBlankStringIfBlank(posterImageUrl); this.school = school; } - private void validate(String name, LocalDate startDate, LocalDate endDate, String thumbnail) { - validateName(name); - validateThumbnail(thumbnail); - validateDate(startDate, endDate); + private void validateName(String name) { + String fieldName = "name"; + Validator.notBlank(name, fieldName); + Validator.maxLength(name, MAX_NAME_LENGTH, fieldName); } - private void validateName(String name) { - Assert.notNull(name, "name은 null 값이 될 수 없습니다."); - Validator.maxLength(name, 50, "name은 50글자를 넘을 수 없습니다."); + private void validatePosterImageUrl(String posterImageUrl) { + Validator.maxLength(posterImageUrl, MAX_POSTER_IMAGE_URL_LENGTH, "posterImageUrl"); } - private void validateThumbnail(String thumbnail) { - Assert.notNull(thumbnail, "thumbnail은 null 값이 될 수 없습니다."); - Validator.maxLength(thumbnail, 255, "thumbnail은 50글자를 넘을 수 없습니다."); + private void validateFestivalDuration(FestivalDuration festivalDuration) { + Validator.notNull(festivalDuration, "festivalDuration"); } - private void validateDate(LocalDate startDate, LocalDate endDate) { - Assert.notNull(startDate, "startDate는 null 값이 될 수 없습니다."); - Assert.notNull(endDate, "endDate는 null 값이 될 수 없습니다."); - if (startDate.isAfter(endDate)) { - throw new IllegalArgumentException("축제 시작 일은 종료일 이전이어야 합니다."); - } + private void validateSchool(School school) { + Validator.notNull(school, "school"); } - public boolean canCreate(LocalDate currentDate) { - return startDate.isEqual(currentDate) || startDate.isAfter(currentDate); + public boolean isStartDateBeforeTo(LocalDate date) { + return festivalDuration.isStartDateBeforeTo(date); } - public boolean isNotInDuration(LocalDateTime time) { - LocalDate date = time.toLocalDate(); - return date.isBefore(startDate) || date.isAfter(endDate); + public boolean isNotInDuration(LocalDateTime dateTime) { + return festivalDuration.isNotInDuration(dateTime.toLocalDate()); } public void changeName(String name) { @@ -101,15 +91,14 @@ public void changeName(String name) { this.name = name; } - public void changeThumbnail(String thumbnail) { - validateThumbnail(thumbnail); - this.thumbnail = thumbnail; + public void changePosterImageUrl(String posterImageUrl) { + validatePosterImageUrl(posterImageUrl); + this.posterImageUrl = ImageUrlHelper.getBlankStringIfBlank(posterImageUrl); } - public void changeDate(LocalDate startDate, LocalDate endDate) { - validateDate(startDate, endDate); - this.startDate = startDate; - this.endDate = endDate; + public void changeFestivalDuration(FestivalDuration festivalDuration) { + validateFestivalDuration(festivalDuration); + this.festivalDuration = festivalDuration; } public Long getId() { @@ -121,15 +110,15 @@ public String getName() { } public LocalDate getStartDate() { - return startDate; + return festivalDuration.getStartDate(); } public LocalDate getEndDate() { - return endDate; + return festivalDuration.getEndDate(); } - public String getThumbnail() { - return thumbnail; + public String getPosterImageUrl() { + return posterImageUrl; } public School getSchool() { diff --git a/backend/src/main/java/com/festago/festival/domain/FestivalDuration.java b/backend/src/main/java/com/festago/festival/domain/FestivalDuration.java new file mode 100644 index 000000000..bf608d2ee --- /dev/null +++ b/backend/src/main/java/com/festago/festival/domain/FestivalDuration.java @@ -0,0 +1,47 @@ +package com.festago.festival.domain; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.util.Validator; +import jakarta.persistence.Embeddable; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FestivalDuration { + + private LocalDate startDate; + private LocalDate endDate; + + public FestivalDuration(LocalDate startDate, LocalDate endDate) { + validate(startDate, endDate); + this.startDate = startDate; + this.endDate = endDate; + } + + private void validate(LocalDate startDate, LocalDate endDate) { + Validator.notNull(startDate, "startDate"); + Validator.notNull(endDate, "endDate"); + if (startDate.isAfter(endDate)) { + throw new BadRequestException(ErrorCode.INVALID_FESTIVAL_DURATION); + } + } + + public boolean isStartDateBeforeTo(LocalDate date) { + return startDate.isBefore(date); + } + + public boolean isNotInDuration(LocalDate date) { + return date.isBefore(startDate) || date.isAfter(endDate); + } + + public LocalDate getStartDate() { + return startDate; + } + + public LocalDate getEndDate() { + return endDate; + } +} diff --git a/backend/src/main/java/com/festago/festival/domain/FestivalIdStageArtistsResolver.java b/backend/src/main/java/com/festago/festival/domain/FestivalIdStageArtistsResolver.java new file mode 100644 index 000000000..5a905756e --- /dev/null +++ b/backend/src/main/java/com/festago/festival/domain/FestivalIdStageArtistsResolver.java @@ -0,0 +1,13 @@ +package com.festago.festival.domain; + +import com.festago.artist.domain.Artist; +import java.util.List; + +/** + * Festival 식별자를 인자로 받고, 해당 Festival 식별자를 참조하는 Stage가 가진 모든 Artist를 반환하는 인터페이스 + */ +@FunctionalInterface +public interface FestivalIdStageArtistsResolver { + + List resolve(Long festivalId); +} diff --git a/backend/src/main/java/com/festago/festival/domain/FestivalQueryInfo.java b/backend/src/main/java/com/festago/festival/domain/FestivalQueryInfo.java new file mode 100644 index 000000000..21035ab6c --- /dev/null +++ b/backend/src/main/java/com/festago/festival/domain/FestivalQueryInfo.java @@ -0,0 +1,56 @@ +package com.festago.festival.domain; + +import com.festago.artist.domain.Artist; +import com.festago.artist.domain.ArtistsSerializer; +import com.festago.common.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FestivalQueryInfo extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @Column(unique = true) + private Long festivalId; + + @NotNull + @Column(columnDefinition = "TEXT") + private String artistInfo; + + private FestivalQueryInfo(Long festivalId, String artistInfo) { + this.festivalId = festivalId; + this.artistInfo = artistInfo; + } + + public static FestivalQueryInfo create(Long festivalId) { + return new FestivalQueryInfo(festivalId, "[]"); + } + + public void updateArtistInfo(List artists, ArtistsSerializer serializer) { + this.artistInfo = serializer.serialize(artists); + } + + public Long getId() { + return id; + } + + public Long getFestivalId() { + return festivalId; + } + + public String getArtistInfo() { + return artistInfo; + } +} diff --git a/backend/src/main/java/com/festago/festival/domain/validator/FestivalDeleteValidator.java b/backend/src/main/java/com/festago/festival/domain/validator/FestivalDeleteValidator.java new file mode 100644 index 000000000..15f49105e --- /dev/null +++ b/backend/src/main/java/com/festago/festival/domain/validator/FestivalDeleteValidator.java @@ -0,0 +1,6 @@ +package com.festago.festival.domain.validator; + +public interface FestivalDeleteValidator { + + void validate(Long festivalId); +} diff --git a/backend/src/main/java/com/festago/festival/domain/validator/FestivalUpdateValidator.java b/backend/src/main/java/com/festago/festival/domain/validator/FestivalUpdateValidator.java new file mode 100644 index 000000000..c4f27a83d --- /dev/null +++ b/backend/src/main/java/com/festago/festival/domain/validator/FestivalUpdateValidator.java @@ -0,0 +1,8 @@ +package com.festago.festival.domain.validator; + +import com.festago.festival.domain.Festival; + +public interface FestivalUpdateValidator { + + void validate(Festival festival); +} diff --git a/backend/src/main/java/com/festago/festival/domain/validator/school/ExistsFestivalSchoolDeleteValidator.java b/backend/src/main/java/com/festago/festival/domain/validator/school/ExistsFestivalSchoolDeleteValidator.java new file mode 100644 index 000000000..79859e6bc --- /dev/null +++ b/backend/src/main/java/com/festago/festival/domain/validator/school/ExistsFestivalSchoolDeleteValidator.java @@ -0,0 +1,24 @@ +package com.festago.festival.domain.validator.school; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.validator.SchoolDeleteValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ExistsFestivalSchoolDeleteValidator implements SchoolDeleteValidator { + + private final FestivalRepository festivalRepository; + + @Override + public void validate(Long schoolId) { + if (festivalRepository.existsBySchoolId(schoolId)) { + throw new BadRequestException(ErrorCode.SCHOOL_DELETE_CONSTRAINT_EXISTS_FESTIVAL); + } + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalCreateRequest.java b/backend/src/main/java/com/festago/festival/dto/FestivalCreateRequest.java deleted file mode 100644 index 3fe810347..000000000 --- a/backend/src/main/java/com/festago/festival/dto/FestivalCreateRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.festago.festival.dto; - -import com.festago.festival.domain.Festival; -import com.festago.school.domain.School; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.format.annotation.DateTimeFormat.ISO; - -public record FestivalCreateRequest( - @NotBlank(message = "name은 공백일 수 없습니다.") - String name, - @NotNull(message = "startDate는 null 일 수 없습니다.") - @DateTimeFormat(iso = ISO.DATE) - LocalDate startDate, - @NotNull(message = "endDate는 null 일 수 없습니다.") - @DateTimeFormat(iso = ISO.DATE) - LocalDate endDate, - String thumbnail, - @NotNull(message = "schoolId는 null 일 수 없습니다.") - Long schoolId) { - - public Festival toEntity(School school) { - if (thumbnail == null || thumbnail.isBlank()) { - return new Festival(name, startDate, endDate, school); - } - return new Festival(name, startDate, endDate, thumbnail, school); - } -} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalDetailResponse.java b/backend/src/main/java/com/festago/festival/dto/FestivalDetailResponse.java deleted file mode 100644 index 8b6d0c950..000000000 --- a/backend/src/main/java/com/festago/festival/dto/FestivalDetailResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.festago.festival.dto; - -import com.festago.festival.domain.Festival; -import com.festago.stage.domain.Stage; -import java.time.LocalDate; -import java.util.List; - -public record FestivalDetailResponse( - Long id, - Long schoolId, - String name, - LocalDate startDate, - LocalDate endDate, - String thumbnail, - List stages) { - - public static FestivalDetailResponse of(Festival festival, List stages) { - List stageResponses = stages.stream() - .map(FestivalDetailStageResponse::from) - .toList(); - return new FestivalDetailResponse( - festival.getId(), - festival.getSchool().getId(), - festival.getName(), - festival.getStartDate(), - festival.getEndDate(), - festival.getThumbnail(), - stageResponses - ); - } -} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalDetailStageResponse.java b/backend/src/main/java/com/festago/festival/dto/FestivalDetailStageResponse.java deleted file mode 100644 index e9704cec8..000000000 --- a/backend/src/main/java/com/festago/festival/dto/FestivalDetailStageResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.festago.festival.dto; - -import com.festago.stage.domain.Stage; -import java.time.LocalDateTime; -import java.util.List; - -public record FestivalDetailStageResponse( - Long id, - LocalDateTime startTime, - LocalDateTime ticketOpenTime, - String lineUp, - List tickets) { - - public static FestivalDetailStageResponse from(Stage stage) { - List tickets = stage.getTickets().stream() - .map(FestivalDetailTicketResponse::from) - .toList(); - return new FestivalDetailStageResponse( - stage.getId(), - stage.getStartTime(), - stage.getTicketOpenTime(), - stage.getLineUp(), - tickets - ); - } -} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalDetailTicketResponse.java b/backend/src/main/java/com/festago/festival/dto/FestivalDetailTicketResponse.java deleted file mode 100644 index 971042219..000000000 --- a/backend/src/main/java/com/festago/festival/dto/FestivalDetailTicketResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.festago.festival.dto; - -import com.festago.ticket.domain.Ticket; -import com.festago.ticket.domain.TicketAmount; -import com.festago.ticket.domain.TicketType; - -public record FestivalDetailTicketResponse( - Long id, - TicketType ticketType, - Integer totalAmount, - Integer remainAmount) { - - public static FestivalDetailTicketResponse from(Ticket ticket) { - TicketAmount ticketAmount = ticket.getTicketAmount(); - return new FestivalDetailTicketResponse( - ticket.getId(), - ticket.getTicketType(), - ticketAmount.getTotalAmount(), - ticketAmount.calculateRemainAmount() - ); - } -} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java b/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java new file mode 100644 index 000000000..0f62e9d12 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/FestivalDetailV1Response.java @@ -0,0 +1,21 @@ +package com.festago.festival.dto; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; +import java.util.Set; + +public record FestivalDetailV1Response( + Long id, + String name, + SchoolV1Response school, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl, + Set socialMedias, + Set stages +) { + + @QueryProjection + public FestivalDetailV1Response { + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalResponse.java b/backend/src/main/java/com/festago/festival/dto/FestivalResponse.java deleted file mode 100644 index ad4bac4ff..000000000 --- a/backend/src/main/java/com/festago/festival/dto/FestivalResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.festago.festival.dto; - -import com.festago.festival.domain.Festival; -import java.time.LocalDate; - -public record FestivalResponse( - Long id, - Long schoolId, - String name, - LocalDate startDate, - LocalDate endDate, - String thumbnail) { - - public static FestivalResponse from(Festival festival) { - return new FestivalResponse( - festival.getId(), - festival.getSchool().getId(), - festival.getName(), - festival.getStartDate(), - festival.getEndDate(), - festival.getThumbnail()); - } -} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalSearchV1Response.java b/backend/src/main/java/com/festago/festival/dto/FestivalSearchV1Response.java new file mode 100644 index 000000000..adb6d5cdc --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/FestivalSearchV1Response.java @@ -0,0 +1,25 @@ +package com.festago.festival.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.festago.artist.infrastructure.JsonArtistsSerializer; +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +public record FestivalSearchV1Response( + Long id, + String name, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl, + @JsonRawValue + @ArraySchema(schema = @Schema(implementation = JsonArtistsSerializer.ArtistQueryModel.class)) + String artists +) { + + @QueryProjection + public FestivalSearchV1Response { + + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalUpdateRequest.java b/backend/src/main/java/com/festago/festival/dto/FestivalUpdateRequest.java deleted file mode 100644 index 2609b7451..000000000 --- a/backend/src/main/java/com/festago/festival/dto/FestivalUpdateRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.festago.festival.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.format.annotation.DateTimeFormat.ISO; - -public record FestivalUpdateRequest( - @NotBlank(message = "name은 공백일 수 없습니다.") String name, - @NotNull(message = "startDate는 null일 수 없습니다.") @DateTimeFormat(iso = ISO.DATE) LocalDate startDate, - @NotNull(message = "endDate는 null일 수 없습니다.") @DateTimeFormat(iso = ISO.DATE) LocalDate endDate, - String thumbnail -) { - -} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalV1QueryRequest.java b/backend/src/main/java/com/festago/festival/dto/FestivalV1QueryRequest.java new file mode 100644 index 000000000..338d614bd --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/FestivalV1QueryRequest.java @@ -0,0 +1,14 @@ +package com.festago.festival.dto; + +import com.festago.festival.repository.FestivalFilter; +import com.festago.school.domain.SchoolRegion; +import java.time.LocalDate; + +public record FestivalV1QueryRequest( + SchoolRegion location, + FestivalFilter filter, + Long lastFestivalId, + LocalDate lastStartDate +) { + +} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalV1Response.java b/backend/src/main/java/com/festago/festival/dto/FestivalV1Response.java new file mode 100644 index 000000000..c82ba55a5 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/FestivalV1Response.java @@ -0,0 +1,25 @@ +package com.festago.festival.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.festago.artist.infrastructure.JsonArtistsSerializer; +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +public record FestivalV1Response( + Long id, + String name, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl, + SchoolV1Response school, + @JsonRawValue + @ArraySchema(schema = @Schema(implementation = JsonArtistsSerializer.ArtistQueryModel.class)) + String artists +) { + + @QueryProjection + public FestivalV1Response { + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/FestivalsResponse.java b/backend/src/main/java/com/festago/festival/dto/FestivalsResponse.java deleted file mode 100644 index 44b04c1b1..000000000 --- a/backend/src/main/java/com/festago/festival/dto/FestivalsResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.festago.festival.dto; - -import static java.util.stream.Collectors.collectingAndThen; -import static java.util.stream.Collectors.toList; - -import com.festago.festival.domain.Festival; -import java.util.List; - -public record FestivalsResponse( - List festivals) { - - public static FestivalsResponse from(List festivals) { - return festivals.stream() - .map(FestivalResponse::from) - .collect(collectingAndThen(toList(), FestivalsResponse::new)); - } -} diff --git a/backend/src/main/java/com/festago/festival/dto/PopularFestivalsV1Response.java b/backend/src/main/java/com/festago/festival/dto/PopularFestivalsV1Response.java new file mode 100644 index 000000000..567363b50 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/PopularFestivalsV1Response.java @@ -0,0 +1,9 @@ +package com.festago.festival.dto; + +import java.util.List; + +public record PopularFestivalsV1Response( + String title, + List content) { + +} diff --git a/backend/src/main/java/com/festago/festival/dto/SchoolV1Response.java b/backend/src/main/java/com/festago/festival/dto/SchoolV1Response.java new file mode 100644 index 000000000..4e3fc8fa8 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/SchoolV1Response.java @@ -0,0 +1,18 @@ +package com.festago.festival.dto; + +import com.festago.school.domain.School; +import com.querydsl.core.annotations.QueryProjection; + +public record SchoolV1Response( + Long id, + String name +) { + + @QueryProjection + public SchoolV1Response { + } + + public static SchoolV1Response from(School school) { + return new SchoolV1Response(school.getId(), school.getName()); + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/SocialMediaV1Response.java b/backend/src/main/java/com/festago/festival/dto/SocialMediaV1Response.java new file mode 100644 index 000000000..f0c3b57fd --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/SocialMediaV1Response.java @@ -0,0 +1,16 @@ +package com.festago.festival.dto; + +import com.festago.socialmedia.domain.SocialMediaType; +import com.querydsl.core.annotations.QueryProjection; + +public record SocialMediaV1Response( + SocialMediaType type, + String name, + String logoUrl, + String url +) { + + @QueryProjection + public SocialMediaV1Response { + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/StageV1Response.java b/backend/src/main/java/com/festago/festival/dto/StageV1Response.java new file mode 100644 index 000000000..efa41987a --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/StageV1Response.java @@ -0,0 +1,21 @@ +package com.festago.festival.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.festago.artist.infrastructure.JsonArtistsSerializer; +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record StageV1Response( + Long id, + LocalDateTime startDateTime, + @JsonRawValue + @ArraySchema(schema = @Schema(implementation = JsonArtistsSerializer.ArtistQueryModel.class)) + String artists +) { + + @QueryProjection + public StageV1Response { + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/command/FestivalCreateCommand.java b/backend/src/main/java/com/festago/festival/dto/command/FestivalCreateCommand.java new file mode 100644 index 000000000..f5f1b551f --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/command/FestivalCreateCommand.java @@ -0,0 +1,21 @@ +package com.festago.festival.dto.command; + +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalDuration; +import com.festago.school.domain.School; +import java.time.LocalDate; +import lombok.Builder; + +@Builder +public record FestivalCreateCommand( + String name, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl, + Long schoolId +) { + + public Festival toEntity(School school) { + return new Festival(name, new FestivalDuration(startDate, endDate), posterImageUrl, school); + } +} diff --git a/backend/src/main/java/com/festago/festival/dto/command/FestivalUpdateCommand.java b/backend/src/main/java/com/festago/festival/dto/command/FestivalUpdateCommand.java new file mode 100644 index 000000000..6fadfcf8d --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/command/FestivalUpdateCommand.java @@ -0,0 +1,14 @@ +package com.festago.festival.dto.command; + +import java.time.LocalDate; +import lombok.Builder; + +@Builder +public record FestivalUpdateCommand( + String name, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl +) { + +} diff --git a/backend/src/main/java/com/festago/festival/dto/event/FestivalCreatedEvent.java b/backend/src/main/java/com/festago/festival/dto/event/FestivalCreatedEvent.java new file mode 100644 index 000000000..11a3ca9a8 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/event/FestivalCreatedEvent.java @@ -0,0 +1,9 @@ +package com.festago.festival.dto.event; + +import com.festago.festival.domain.Festival; + +public record FestivalCreatedEvent( + Festival festival +) { + +} diff --git a/backend/src/main/java/com/festago/festival/dto/event/FestivalDeletedEvent.java b/backend/src/main/java/com/festago/festival/dto/event/FestivalDeletedEvent.java new file mode 100644 index 000000000..8b3f0a931 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/event/FestivalDeletedEvent.java @@ -0,0 +1,7 @@ +package com.festago.festival.dto.event; + +public record FestivalDeletedEvent( + Long festivalId +) { + +} diff --git a/backend/src/main/java/com/festago/festival/dto/event/FestivalUpdatedEvent.java b/backend/src/main/java/com/festago/festival/dto/event/FestivalUpdatedEvent.java new file mode 100644 index 000000000..176d7b00e --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/event/FestivalUpdatedEvent.java @@ -0,0 +1,9 @@ +package com.festago.festival.dto.event; + +import com.festago.festival.domain.Festival; + +public record FestivalUpdatedEvent( + Festival festival +) { + +} diff --git a/backend/src/main/java/com/festago/festival/presentation/v1/FestivalSearchV1Controller.java b/backend/src/main/java/com/festago/festival/presentation/v1/FestivalSearchV1Controller.java new file mode 100644 index 000000000..b3947eb77 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/presentation/v1/FestivalSearchV1Controller.java @@ -0,0 +1,34 @@ +package com.festago.festival.presentation.v1; + +import com.festago.common.util.Validator; +import com.festago.festival.application.FestivalSearchV1QueryService; +import com.festago.festival.dto.FestivalSearchV1Response; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/search/festivals") +@Tag(name = "축제 검색 요청 V1") +@RequiredArgsConstructor +public class FestivalSearchV1Controller { + + private final FestivalSearchV1QueryService festivalSearchV1QueryService; + + @GetMapping + @Operation(description = "키워드로 축제를 검색한다. ~대, ~대학교로 끝날 시 대학교 축제 검색, 그 외의 경우 아티스트가 참여한 축제 검색.", summary = "축제 검색") + public ResponseEntity> searchFestivals(@RequestParam String keyword) { + validate(keyword); + return ResponseEntity.ok(festivalSearchV1QueryService.search(keyword)); + } + + private void validate(String keyword) { + Validator.notBlank(keyword, "keyword"); + } +} diff --git a/backend/src/main/java/com/festago/festival/presentation/v1/FestivalV1Controller.java b/backend/src/main/java/com/festago/festival/presentation/v1/FestivalV1Controller.java new file mode 100644 index 000000000..da3386a2f --- /dev/null +++ b/backend/src/main/java/com/festago/festival/presentation/v1/FestivalV1Controller.java @@ -0,0 +1,69 @@ +package com.festago.festival.presentation.v1; + +import com.festago.common.aop.ValidPageable; +import com.festago.common.exception.ValidException; +import com.festago.festival.application.FestivalDetailV1QueryService; +import com.festago.festival.application.FestivalV1QueryService; +import com.festago.festival.dto.FestivalDetailV1Response; +import com.festago.festival.dto.FestivalV1QueryRequest; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.repository.FestivalFilter; +import com.festago.school.domain.SchoolRegion; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/festivals") +@Tag(name = "축제 정보 요청 V1") +@RequiredArgsConstructor +public class FestivalV1Controller { + + private final FestivalV1QueryService festivalV1QueryService; + private final FestivalDetailV1QueryService festivalDetailV1QueryService; + + @GetMapping + @ValidPageable(maxSize = 20) + @Operation(description = "축제 목록를 조건별로 조회한다.", summary = "축제 목록 조회") + public ResponseEntity> findFestivals( + @Parameter(description = "0 < size <= 20") @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "ANY") SchoolRegion region, + @Parameter(description = "PROGRESS: 진행 중, PLANNED: 진행 예정, END: 종료") @RequestParam(defaultValue = "PROGRESS") FestivalFilter filter, + @RequestParam(required = false) Long lastFestivalId, + @RequestParam(required = false) LocalDate lastStartDate + ) { + validateCursor(lastFestivalId, lastStartDate); + var request = new FestivalV1QueryRequest(region, filter, lastFestivalId, lastStartDate); + var response = festivalV1QueryService.findFestivals(PageRequest.ofSize(size), request); + return ResponseEntity.ok(response); + } + + private void validateCursor(Long lastFestivalId, LocalDate lastStartDate) { + if (lastFestivalId == null && lastStartDate == null) { + return; + } + if (lastFestivalId != null && lastStartDate != null) { + return; + } + throw new ValidException("festivalId, lastStartDate 두 값 모두 요청하거나 요청하지 않아야합니다."); + } + + @GetMapping("/{festivalId}") + @Operation(description = "축제의 정보를 조회한다.", summary = "축제 정보 조회") + public ResponseEntity findFestivalDetail( + @PathVariable Long festivalId + ) { + var response = festivalDetailV1QueryService.findFestivalDetail(festivalId); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/festago/festival/presentation/v1/PopularFestivalV1Controller.java b/backend/src/main/java/com/festago/festival/presentation/v1/PopularFestivalV1Controller.java new file mode 100644 index 000000000..81c8e935e --- /dev/null +++ b/backend/src/main/java/com/festago/festival/presentation/v1/PopularFestivalV1Controller.java @@ -0,0 +1,26 @@ +package com.festago.festival.presentation.v1; + +import com.festago.festival.application.PopularFestivalV1QueryService; +import com.festago.festival.dto.PopularFestivalsV1Response; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/popular/festivals") +@Tag(name = "인기 축제 목록 요청 V1") +@RequiredArgsConstructor +public class PopularFestivalV1Controller { + + private final PopularFestivalV1QueryService popularFestivalV1QueryService; + + @GetMapping + @Operation(description = "인기 축제 목록 7개를 반환한다.", summary = "인기 축제 목록 조회") + public ResponseEntity findPopularFestivals() { + return ResponseEntity.ok(popularFestivalV1QueryService.findPopularFestivals()); + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/ArtistFestivalSearchV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/ArtistFestivalSearchV1QueryDslRepository.java new file mode 100644 index 000000000..ec0f2e666 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/ArtistFestivalSearchV1QueryDslRepository.java @@ -0,0 +1,61 @@ +package com.festago.festival.repository; + +import static com.festago.artist.domain.QArtist.artist; +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.stage.domain.QStageArtist.stageArtist; + +import com.festago.artist.domain.Artist; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.dto.FestivalSearchV1Response; +import com.festago.festival.dto.QFestivalSearchV1Response; +import com.querydsl.core.types.dsl.BooleanExpression; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistFestivalSearchV1QueryDslRepository extends QueryDslRepositorySupport { + + public ArtistFestivalSearchV1QueryDslRepository() { + super(Artist.class); + } + + public List executeSearch(String keyword) { + return select(new QFestivalSearchV1Response( + festival.id, + festival.name, + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + festival.posterImageUrl, + festivalQueryInfo.artistInfo) + ) + .from(artist) + .innerJoin(stageArtist).on(stageArtist.artistId.eq(artist.id)) + .innerJoin(stage).on(stage.id.eq(stageArtist.stageId)) + .innerJoin(festival).on(festival.id.eq(stage.festival.id)) + .innerJoin(festivalQueryInfo).on(festival.id.eq(festivalQueryInfo.festivalId)) + .where(getBooleanExpressionByKeyword(keyword)) + .fetch(); + } + + private BooleanExpression getBooleanExpressionByKeyword(String keyword) { + int keywordLength = keyword.length(); + if (keywordLength == 0) { + throw new BadRequestException(ErrorCode.INVALID_KEYWORD); + } + if (keywordLength == 1) { + return artist.name.eq(keyword); + } + return artist.name.contains(keyword); + } + + public boolean existsByName(String keyword) { + return !selectFrom(artist) + .where(getBooleanExpressionByKeyword(keyword)) + .fetch() + .isEmpty(); + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalDetailV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalDetailV1QueryDslRepository.java new file mode 100644 index 000000000..73380c8ec --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/FestivalDetailV1QueryDslRepository.java @@ -0,0 +1,80 @@ +package com.festago.festival.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.school.domain.QSchool.school; +import static com.festago.socialmedia.domain.QSocialMedia.socialMedia; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.stage.domain.QStageQueryInfo.stageQueryInfo; +import static com.querydsl.core.group.GroupBy.groupBy; +import static com.querydsl.core.group.GroupBy.sortedSet; + +import com.festago.common.exception.UnexpectedException; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.FestivalDetailV1Response; +import com.festago.festival.dto.QFestivalDetailV1Response; +import com.festago.festival.dto.QSchoolV1Response; +import com.festago.festival.dto.QSocialMediaV1Response; +import com.festago.festival.dto.QStageV1Response; +import com.festago.festival.dto.SocialMediaV1Response; +import com.festago.festival.dto.StageV1Response; +import com.festago.socialmedia.domain.OwnerType; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public class FestivalDetailV1QueryDslRepository extends QueryDslRepositorySupport { + + public FestivalDetailV1QueryDslRepository() { + super(Festival.class); + } + + /** + * 축제에 3개의 공연과 2개의 소셜미디어가 있을 때 조회되는 row 수는 다음과 같다.
1(축제) * 3(공연) * 2(소셜미디어) = 6 row
따라서 중복된 row가 생기게 + * 되는데, 이를 해결하기 위해 set을 사용했고, 항상 일관되게 정렬된 데이터를 조회하기 위해 sortedSet을 사용했음
+ */ + public Optional findFestivalDetail(Long festivalId) { + List response = selectFrom(festival) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .leftJoin(socialMedia).on(socialMedia.ownerId.eq(school.id).and(socialMedia.ownerType.eq(OwnerType.SCHOOL))) + .leftJoin(stage).on(stage.festival.id.eq(festival.id)) + .leftJoin(stageQueryInfo).on(stageQueryInfo.stageId.eq(stage.id)) + .where(festival.id.eq(festivalId)) + .transform( + groupBy(festival.id).list( + new QFestivalDetailV1Response( + festival.id, + festival.name, + new QSchoolV1Response( + school.id, + school.name + ), + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + festival.posterImageUrl, + sortedSet(new QSocialMediaV1Response( + socialMedia.mediaType, + socialMedia.name, + socialMedia.logoUrl, + socialMedia.url + ).skipNulls(), Comparator.comparing(SocialMediaV1Response::name)), + sortedSet(new QStageV1Response( + stage.id, + stage.startTime, + stageQueryInfo.artistInfo + ).skipNulls(), Comparator.comparing(StageV1Response::startDateTime)) + ) + ) + ); + if (response.isEmpty()) { + return Optional.empty(); + } + // PK로 조회하기에 발생할 일이 없는 예외지만, 혹시 모를 상황을 방지하기 위함 + if (response.size() >= 2) { + throw new UnexpectedException("축제 상세 조회에서 2개 이상의 축제가 조회되었습니다."); + } + return Optional.of(response.get(0)); + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalFilter.java b/backend/src/main/java/com/festago/festival/repository/FestivalFilter.java new file mode 100644 index 000000000..7a328b631 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/FestivalFilter.java @@ -0,0 +1,19 @@ +package com.festago.festival.repository; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; + +public enum FestivalFilter { + PROGRESS, + PLANNED, + END; + + public static FestivalFilter from(String filterName) { + return switch (filterName.toUpperCase()) { + case "PROGRESS" -> PROGRESS; + case "PLANNED" -> PLANNED; + case "END" -> END; + default -> throw new BadRequestException(ErrorCode.INVALID_FESTIVAL_FILTER); + }; + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalInfoRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalInfoRepository.java new file mode 100644 index 000000000..56c44fd56 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/FestivalInfoRepository.java @@ -0,0 +1,15 @@ +package com.festago.festival.repository; + +import com.festago.festival.domain.FestivalQueryInfo; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface FestivalInfoRepository extends Repository { + + FestivalQueryInfo save(FestivalQueryInfo festivalQueryInfo); + + Optional findByFestivalId(Long festivalId); + + void deleteByFestivalId(Long festivalId); +} + diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java index 10b3c02c6..8b3ef5d34 100644 --- a/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java +++ b/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java @@ -1,8 +1,25 @@ package com.festago.festival.repository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; import com.festago.festival.domain.Festival; -import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import org.springframework.data.repository.Repository; -public interface FestivalRepository extends JpaRepository { +public interface FestivalRepository extends Repository { + default Festival getOrThrow(Long festivalId) { + return findById(festivalId) + .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); + } + + boolean existsBySchoolId(Long schoolId); + + Festival save(Festival festival); + + Optional findById(Long festivalId); + + void deleteById(Long festivalId); + + boolean existsById(Long festivalId); } diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalSearchCondition.java b/backend/src/main/java/com/festago/festival/repository/FestivalSearchCondition.java new file mode 100644 index 000000000..ca379bdff --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/FestivalSearchCondition.java @@ -0,0 +1,16 @@ +package com.festago.festival.repository; + +import com.festago.school.domain.SchoolRegion; +import java.time.LocalDate; +import org.springframework.data.domain.Pageable; + +public record FestivalSearchCondition( + FestivalFilter filter, + SchoolRegion region, + LocalDate lastStartDate, + Long lastFestivalId, + Pageable pageable, + LocalDate currentTime +) { + +} diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalV1QueryDslRepository.java new file mode 100644 index 000000000..a1f54d356 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/FestivalV1QueryDslRepository.java @@ -0,0 +1,129 @@ +package com.festago.festival.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.school.domain.QSchool.school; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.dto.QFestivalV1Response; +import com.festago.festival.dto.QSchoolV1Response; +import com.festago.school.domain.SchoolRegion; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import java.time.LocalDate; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +@Repository +public class FestivalV1QueryDslRepository extends QueryDslRepositorySupport { + + public FestivalV1QueryDslRepository() { + super(Festival.class); + } + + public Slice findBy(FestivalSearchCondition searchCondition) { + FestivalFilter filter = searchCondition.filter(); + Pageable pageable = searchCondition.pageable(); + return applySlice( + pageable, + query -> query.select(new QFestivalV1Response( + festival.id, + festival.name, + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + festival.posterImageUrl, + new QSchoolV1Response( + school.id, + school.name + ), + festivalQueryInfo.artistInfo + )) + .from(festival) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .innerJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) + .where(dynamicWhere(filter, searchCondition.currentTime(), searchCondition.lastFestivalId(), + searchCondition.lastStartDate(), searchCondition.region())) + .orderBy(dynamicOrderBy(filter)) + ); + } + + private BooleanExpression dynamicWhere( + FestivalFilter filter, + LocalDate currentTime, + Long lastFestivalId, + LocalDate lastStartDate, + SchoolRegion region + ) { + BooleanExpression booleanExpression = getBooleanExpression(filter, currentTime, lastFestivalId, lastStartDate); + booleanExpression = applyRegion(booleanExpression, region); + return booleanExpression; + } + + private BooleanExpression getBooleanExpression( + FestivalFilter filter, + LocalDate currentTime, + Long lastFestivalId, + LocalDate lastStartDate + ) { + if (hasCursor(lastStartDate, lastFestivalId)) { + return getCursorBasedBooleanExpression(filter, currentTime, lastFestivalId, lastStartDate); + } + return getDefaultBooleanExpression(filter, currentTime); + } + + private boolean hasCursor(LocalDate lastStartDate, Long lastFestivalId) { + return lastStartDate != null && lastFestivalId != null; + } + + private BooleanExpression getCursorBasedBooleanExpression( + FestivalFilter filter, + LocalDate currentTime, + Long lastFestivalId, + LocalDate lastStartDate + ) { + return switch (filter) { + case PLANNED -> festival.festivalDuration.startDate.gt(lastStartDate) + .or(festival.festivalDuration.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))); + + case PROGRESS -> festival.festivalDuration.startDate.lt(lastStartDate) + .or(festival.festivalDuration.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))) + .and(festival.festivalDuration.endDate.goe(currentTime)); + + case END -> festival.festivalDuration.endDate.lt(currentTime); + }; + } + + private BooleanExpression getDefaultBooleanExpression( + FestivalFilter filter, + LocalDate currentTime + ) { + return switch (filter) { + case PLANNED -> festival.festivalDuration.startDate.gt(currentTime); + + case PROGRESS -> festival.festivalDuration.startDate.loe(currentTime) + .and(festival.festivalDuration.endDate.goe(currentTime)); + + case END -> festival.festivalDuration.endDate.lt(currentTime); + }; + } + + private BooleanExpression applyRegion(BooleanExpression booleanExpression, SchoolRegion region) { + if (region == SchoolRegion.ANY) { + return booleanExpression; + } + return booleanExpression.and(school.region.eq(region)); + } + + private OrderSpecifier[] dynamicOrderBy(FestivalFilter filter) { + return switch (filter) { + case PLANNED -> new OrderSpecifier[]{festival.festivalDuration.startDate.asc(), festival.id.asc()}; + case PROGRESS -> new OrderSpecifier[]{festival.festivalDuration.startDate.desc(), festival.id.asc()}; + case END -> new OrderSpecifier[]{festival.festivalDuration.endDate.desc()}; + }; + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/PopularFestivalV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/PopularFestivalV1QueryDslRepository.java new file mode 100644 index 000000000..82e0f39f6 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/PopularFestivalV1QueryDslRepository.java @@ -0,0 +1,50 @@ +package com.festago.festival.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.school.domain.QSchool.school; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.dto.QFestivalV1Response; +import com.festago.festival.dto.QSchoolV1Response; +import java.time.LocalDate; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class PopularFestivalV1QueryDslRepository extends QueryDslRepositorySupport { + + private static final int POPULAR_FESTIVAL_LIMIT_COUNT = 7; + + public PopularFestivalV1QueryDslRepository() { + super(Festival.class); + } + + /** + * 아직 명확한 추천 축제 기준이 없으므로 생성 시간(식별자) 내림차순으로 반환하도록 함 + */ + public List findPopularFestivals(LocalDate now) { + return select(new QFestivalV1Response( + festival.id, + festival.name, + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + festival.posterImageUrl, + new QSchoolV1Response( + school.id, + school.name + ), + festivalQueryInfo.artistInfo) + ) + .from(festival) + .where(festivalQueryInfo.artistInfo.ne("[]") + .and(festival.festivalDuration.endDate.goe(now))) + .innerJoin(school).on(school.id.eq(festival.school.id)) + .innerJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) + .orderBy(festival.id.desc()) + .limit(POPULAR_FESTIVAL_LIMIT_COUNT) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/RecentSchoolFestivalV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/RecentSchoolFestivalV1QueryDslRepository.java new file mode 100644 index 000000000..78c630b84 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/RecentSchoolFestivalV1QueryDslRepository.java @@ -0,0 +1,34 @@ +package com.festago.festival.repository; + +import static com.festago.festival.domain.QFestival.festival; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.domain.Festival; +import com.festago.school.dto.v1.QSchoolUpcomingFestivalStartDateV1Response; +import com.festago.school.dto.v1.SchoolUpcomingFestivalStartDateV1Response; +import java.time.LocalDate; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class RecentSchoolFestivalV1QueryDslRepository extends QueryDslRepositorySupport { + + public RecentSchoolFestivalV1QueryDslRepository() { + super(Festival.class); + } + + public List findRecentSchoolFestivals( + List schoolIds, + LocalDate now + ) { + return select( + new QSchoolUpcomingFestivalStartDateV1Response( + festival.school.id, + festival.festivalDuration.startDate.min() + )) + .from(festival) + .where(festival.school.id.in(schoolIds).and(festival.festivalDuration.endDate.goe(now))) + .groupBy(festival.school.id) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/SchoolFestivalSearchV1QueryDslRepository.java b/backend/src/main/java/com/festago/festival/repository/SchoolFestivalSearchV1QueryDslRepository.java new file mode 100644 index 000000000..342474ef5 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/SchoolFestivalSearchV1QueryDslRepository.java @@ -0,0 +1,41 @@ +package com.festago.festival.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.festival.dto.FestivalSearchV1Response; +import com.festago.festival.dto.QFestivalSearchV1Response; +import com.festago.school.domain.School; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class SchoolFestivalSearchV1QueryDslRepository extends QueryDslRepositorySupport { + + protected SchoolFestivalSearchV1QueryDslRepository() { + super(School.class); + } + + public List executeSearch(String keyword) { + int keywordLength = keyword.length(); + if (keywordLength == 0) { + throw new BadRequestException(ErrorCode.INVALID_KEYWORD); + } + + return select( + new QFestivalSearchV1Response( + festival.id, + festival.name, + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + festival.posterImageUrl, + festivalQueryInfo.artistInfo)) + .from(festival) + .innerJoin(festivalQueryInfo).on(festival.id.eq(festivalQueryInfo.festivalId)) + .where(festival.name.contains(keyword)) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/member/config/DefaultNicknamePolicyConfig.java b/backend/src/main/java/com/festago/member/config/DefaultNicknamePolicyConfig.java new file mode 100644 index 000000000..f4565eb5e --- /dev/null +++ b/backend/src/main/java/com/festago/member/config/DefaultNicknamePolicyConfig.java @@ -0,0 +1,27 @@ +package com.festago.member.config; + +import com.festago.member.domain.DefaultNicknamePolicy; +import com.festago.member.infrastructure.DefaultNicknamePolicyImpl; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DefaultNicknamePolicyConfig { + + @Bean + public DefaultNicknamePolicy defaultNicknamePolicy() { + List adjectives = List.of( + "츄러스를 먹는", "노래 부르는", "때창하는", "응원하는", + "응원봉을 든", "타코야끼를 먹는", "공연에 심취한", "신나는", + "춤추는", "행복한", "즐거운", "신나는", "흥겨운" + ); + List nouns = List.of( + "다람쥐", "토끼", "고양이", "펭귄", + "캥거루", "사슴", "미어캣", "호랑이", + "여우", "판다", "고슴도치", "토끼", + "햄스터", "얼룩말", "너구리", "치타" + ); + return new DefaultNicknamePolicyImpl(adjectives, nouns); + } +} diff --git a/backend/src/main/java/com/festago/member/domain/DefaultNicknamePolicy.java b/backend/src/main/java/com/festago/member/domain/DefaultNicknamePolicy.java new file mode 100644 index 000000000..042b3392c --- /dev/null +++ b/backend/src/main/java/com/festago/member/domain/DefaultNicknamePolicy.java @@ -0,0 +1,6 @@ +package com.festago.member.domain; + +public interface DefaultNicknamePolicy { + + String generate(); +} diff --git a/backend/src/main/java/com/festago/member/domain/Member.java b/backend/src/main/java/com/festago/member/domain/Member.java index 2ee68e879..a8c96cd05 100644 --- a/backend/src/main/java/com/festago/member/domain/Member.java +++ b/backend/src/main/java/com/festago/member/domain/Member.java @@ -2,6 +2,9 @@ import com.festago.auth.domain.SocialType; import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.util.ImageUrlHelper; +import com.festago.common.util.Validator; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -17,17 +20,17 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.Where; +import org.springframework.util.StringUtils; @Entity -@SQLDelete(sql = "UPDATE member SET deleted_at = now(), nickname = '탈퇴한 회원', profile_image = '', social_id = null WHERE id=?") +@SQLDelete(sql = "UPDATE member SET deleted_at = now(), nickname = '탈퇴한 회원', profile_image_url = '', social_id = null WHERE id=?") @Where(clause = "deleted_at is null") @Table( uniqueConstraints = { @UniqueConstraint( - name = "SOCIAL_UNIQUE", columnNames = { - "socialId", - "socialType" + "social_id", + "social_type" } ) } @@ -35,33 +38,34 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member extends BaseTimeEntity { - private static final String DEFAULT_IMAGE_URL = "https://festa-go.site/images/default-profile.png"; + private static final int MAX_SOCIAL_ID_LENGTH = 255; + private static final int MAX_NICKNAME_LENGTH = 30; + private static final int MAX_PROFILE_IMAGE_LENGTH = 255; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Size(max = 255) + @Size(max = MAX_SOCIAL_ID_LENGTH) + @Column(name = "social_id") private String socialId; @NotNull @Enumerated(value = EnumType.STRING) + @Column(name = "social_type", columnDefinition = "varchar") private SocialType socialType; @NotNull - @Size(max = 30) + @Size(max = MAX_NICKNAME_LENGTH) private String nickname; @NotNull - @Size(max = 255) + @Size(max = MAX_PROFILE_IMAGE_LENGTH) + @Column(name = "profile_image_url") private String profileImage; private LocalDateTime deletedAt = null; - public Member(Long id) { - this.id = id; - } - public Member(String socialId, SocialType socialType, String nickname, String profileImage) { this(null, socialId, socialType, nickname, profileImage); } @@ -72,35 +76,34 @@ public Member(Long id, String socialId, SocialType socialType, String nickname, this.socialId = socialId; this.socialType = socialType; this.nickname = nickname; - this.profileImage = (profileImage != null) ? profileImage : DEFAULT_IMAGE_URL; + this.profileImage = ImageUrlHelper.getBlankStringIfBlank(profileImage); } private void validate(String socialId, SocialType socialType, String nickname, String profileImage) { - checkNotNull(socialId, socialType, nickname); - checkLength(socialId, nickname, profileImage); + validateSocialId(socialId); + validateSocialType(socialType); + validateNickname(nickname); + validateProfileImage(profileImage); + } + + private void validateSocialId(String socialId) { + String fieldName = "socialId"; + Validator.notBlank(socialId, fieldName); + Validator.maxLength(socialId, MAX_SOCIAL_ID_LENGTH, fieldName); } - private void checkNotNull(String socialId, SocialType socialType, String nickname) { - if (socialId == null || - socialType == null || - nickname == null) { - throw new IllegalArgumentException("Member 는 허용되지 않은 null 값으로 생성할 수 없습니다."); - } + private void validateSocialType(SocialType socialType) { + Validator.notNull(socialType, "socialType"); } - private void checkLength(String socialId, String nickname, String profileImage) { - if (overLength(socialId, 255) || - overLength(nickname, 30) || - overLength(profileImage, 255)) { - throw new IllegalArgumentException("Member 의 필드로 허용된 길이를 넘은 column 을 넣을 수 없습니다."); - } + private void validateNickname(String nickname) { + String fieldName = "nickname"; + Validator.maxLength(nickname, MAX_NICKNAME_LENGTH, fieldName); + Validator.notBlank(nickname, fieldName); } - private boolean overLength(String target, int maxLength) { - if (target == null) { - return false; - } - return target.length() > maxLength; + private void validateProfileImage(String profileImage) { + Validator.maxLength(profileImage, MAX_PROFILE_IMAGE_LENGTH, "profileImage"); } public Long getId() { diff --git a/backend/src/main/java/com/festago/member/infrastructure/DefaultNicknamePolicyImpl.java b/backend/src/main/java/com/festago/member/infrastructure/DefaultNicknamePolicyImpl.java new file mode 100644 index 000000000..e3fa4273d --- /dev/null +++ b/backend/src/main/java/com/festago/member/infrastructure/DefaultNicknamePolicyImpl.java @@ -0,0 +1,22 @@ +package com.festago.member.infrastructure; + +import com.festago.member.domain.DefaultNicknamePolicy; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class DefaultNicknamePolicyImpl implements DefaultNicknamePolicy { + + private final List adjectives; + private final List nouns; + + @Override + public String generate() { + Random random = ThreadLocalRandom.current(); + String adjective = adjectives.get(random.nextInt(adjectives.size())); + String noun = nouns.get(random.nextInt(nouns.size())); + return adjective + " " + noun; + } +} diff --git a/backend/src/main/java/com/festago/presentation/MemberController.java b/backend/src/main/java/com/festago/member/presentation/MemberController.java similarity index 90% rename from backend/src/main/java/com/festago/presentation/MemberController.java rename to backend/src/main/java/com/festago/member/presentation/MemberController.java index 66ec99595..9bcd87d02 100644 --- a/backend/src/main/java/com/festago/presentation/MemberController.java +++ b/backend/src/main/java/com/festago/member/presentation/MemberController.java @@ -1,8 +1,9 @@ -package com.festago.presentation; +package com.festago.member.presentation; import com.festago.auth.annotation.Member; import com.festago.member.application.MemberService; import com.festago.member.dto.MemberProfileResponse; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -12,6 +13,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Deprecated(forRemoval = true) +@Hidden @RestController @RequestMapping("/members") @Tag(name = "유저 정보 요청") diff --git a/backend/src/main/java/com/festago/member/repository/MemberRepository.java b/backend/src/main/java/com/festago/member/repository/MemberRepository.java index e9536e0af..0004da02b 100644 --- a/backend/src/main/java/com/festago/member/repository/MemberRepository.java +++ b/backend/src/main/java/com/festago/member/repository/MemberRepository.java @@ -1,11 +1,28 @@ package com.festago.member.repository; import com.festago.auth.domain.SocialType; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; import com.festago.member.domain.Member; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.Repository; -public interface MemberRepository extends JpaRepository { +public interface MemberRepository extends Repository { + + default Member getOrThrow(Long id) { + return findById(id) + .orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND)); + } + + Member save(Member member); + + Optional findById(Long id); + + void delete(Member member); + + boolean existsById(Long id); + + long count(); Optional findBySocialIdAndSocialType(String socialId, SocialType socialType); } diff --git a/backend/src/main/java/com/festago/mock/MockDataInitialScheduler.java b/backend/src/main/java/com/festago/mock/MockDataInitialScheduler.java new file mode 100644 index 000000000..814fce784 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/MockDataInitialScheduler.java @@ -0,0 +1,22 @@ +package com.festago.mock; + +import com.festago.mock.application.MockDataService; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Profile({"dev"}) +@Component +@RequiredArgsConstructor +public class MockDataInitialScheduler { + + private static final long DAYS_OF_WEEK = 7L; + private final MockDataService mockDataService; + + @Scheduled(initialDelay = DAYS_OF_WEEK, fixedDelay = DAYS_OF_WEEK, timeUnit = TimeUnit.DAYS) + public void createMockFestivals() { + mockDataService.makeMockFestivals(); + } +} diff --git a/backend/src/main/java/com/festago/mock/MockDataStartupInitializer.java b/backend/src/main/java/com/festago/mock/MockDataStartupInitializer.java new file mode 100644 index 000000000..70302fe4e --- /dev/null +++ b/backend/src/main/java/com/festago/mock/MockDataStartupInitializer.java @@ -0,0 +1,29 @@ +package com.festago.mock; + +import com.festago.mock.application.MockDataService; +import com.festago.mock.repository.ForMockSchoolRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Profile({"dev"}) +@Component +@RequiredArgsConstructor +public class MockDataStartupInitializer { + + private final ForMockSchoolRepository forMockSchoolRepository; + private final MockDataService mockDataService; + + @Transactional + @EventListener(ApplicationReadyEvent.class) + public void initialize() { + if (forMockSchoolRepository.count() == 0) { + mockDataService.makeMockArtists(); + mockDataService.makeMockSchools(); + mockDataService.makeMockFestivals(); + } + } +} diff --git a/backend/src/main/java/com/festago/mock/application/MockDataService.java b/backend/src/main/java/com/festago/mock/application/MockDataService.java new file mode 100644 index 000000000..83ac4ddac --- /dev/null +++ b/backend/src/main/java/com/festago/mock/application/MockDataService.java @@ -0,0 +1,65 @@ +package com.festago.mock.application; + +import com.festago.artist.domain.Artist; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.event.FestivalCreatedEvent; +import com.festago.mock.domain.MockArtistsGenerator; +import com.festago.mock.domain.MockFestivalsGenerator; +import com.festago.mock.domain.MockSchoolsGenerator; +import com.festago.mock.domain.MockStageArtistsGenerator; +import com.festago.mock.domain.MockStagesGenerator; +import com.festago.mock.repository.ForMockArtistRepository; +import com.festago.mock.repository.ForMockFestivalRepository; +import com.festago.mock.repository.ForMockSchoolRepository; +import com.festago.mock.repository.ForMockStageArtistRepository; +import com.festago.mock.repository.ForMockStageRepository; +import com.festago.school.domain.School; +import com.festago.stage.domain.Stage; +import com.festago.stage.dto.event.StageCreatedEvent; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class MockDataService { + + private final MockArtistsGenerator mockArtistsGenerator; + private final MockSchoolsGenerator mockSchoolsGenerator; + private final MockFestivalsGenerator mockFestivalsGenerator; + private final MockStagesGenerator mockStagesGenerator; + private final MockStageArtistsGenerator mockStageArtistsGenerator; + private final ForMockSchoolRepository schoolRepository; + private final ForMockArtistRepository artistRepository; + private final ForMockFestivalRepository festivalRepository; + private final ForMockStageRepository stageRepository; + private final ForMockStageArtistRepository stageArtistRepository; + private final ApplicationEventPublisher eventPublisher; + + public void makeMockArtists() { + artistRepository.saveAll(mockArtistsGenerator.generate()); + } + + public void makeMockSchools() { + schoolRepository.saveAll(mockSchoolsGenerator.generate()); + } + + public void makeMockFestivals() { + List artists = artistRepository.findAll(); + List schools = schoolRepository.findAll(); + List festivals = festivalRepository.saveAll( + mockFestivalsGenerator.generate(schools) + ); + for (Festival festival : festivals) { + List stages = stageRepository.saveAll(mockStagesGenerator.generate(festival)); + stageArtistRepository.saveAll(mockStageArtistsGenerator.generate(stages, artists)); + eventPublisher.publishEvent(new FestivalCreatedEvent(festival)); + for (Stage stage : stages) { + eventPublisher.publishEvent(new StageCreatedEvent(stage)); + } + } + } +} diff --git a/backend/src/main/java/com/festago/mock/domain/MockArtist.java b/backend/src/main/java/com/festago/mock/domain/MockArtist.java new file mode 100644 index 000000000..c70e4ad70 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/domain/MockArtist.java @@ -0,0 +1,40 @@ +package com.festago.mock.domain; + +public enum MockArtist { + 윤하("https://i.namu.wiki/i/GXgnzRzEe4tq0YmevyZdmLNTPmVK2fzRyZtiCMA-0QSO8vbau9tah4rgP3qNokmT33-El0fx2PauysBruWpTk8B6MLu73gDgVnC3tvFeBnm3Lwo9oRreHM6slID3ilDTyFjpV2eKMO80Jf5u5E920g.webp", + "https://i.namu.wiki/i/IBHythQ09eithIwC-Ix3hr0Sh-AohBVBavl0u-2WW28L6f16JM8F8Klm-0A6gDacgy4t7ATbto7wBc9xt5gwsj0IVG1y9EphSkJun-op4O9OGwvOvX8ES8aTUejOsKPFX5Iat_ubwgGWAYudo5q-Yw.webp"), + 뉴진스("https://i.namu.wiki/i/l1co7IV1KFVJ9HBgzxCXkMbgMfuZp_MJhjhqgB7e76DLuokabw6CNlltqr7HGzAMFqt42JfXF94Cflw5XdDuXTS2QkvomS7WYpiiJbuAn5MAjBxOA_zT93dsgyLO-gJXtV0JN-jEQ4tQ-MWtqbHJyA.webp", + "https://i.namu.wiki/i/j5Fs-OjRcQsjfrrFFUVumAWauv-47tj5WPrfyIcCMuBrV5UeStJwaFK17HKcaKxvME2NVpo5PuxVgRpED1huULNxCYBydqsOs-HCLRD-kMztnZdaMJJvi1VefVB1RN0MnwMdxS7xKzxJa11qem0LMg.svg"), + 아이유("https://i.namu.wiki/i/k-to3_lfqjjcdnXtWMu3aLtZAArBM1nDpDP6cCWz5iJYm3HjJZ3b7i2H-4-KFSkQ6HOeftXIilMOQXvkdp83hu1FdBv5GE_PyYuacNUSygQ2cnT8vfNHqQVUReYdEYY3ob1BWoyGBE6BQRaHmnGPLw.webp", + "https://i.namu.wiki/i/bJDL9DWmKsrHvvMcLOSKMlVv_E62CX6brjhiddhFuLrGPVYN6-bYcJxUHnE_KP04Ok-8PqezYob8OCepRWFBw6CTDE5Jvde2iJOZEqGgYVt6Gdbub0s9pBIOqzI1DQYZvJbAezbh-8xns_ZugPW68g.webp"), + 방탄소년단( + "https://i.namu.wiki/i/EvnNG2DchyHHYmFtyWWzVHhPKkURdc6kdoiRVYisSKHE6BDE8itzfhfYvIMdoX0-6wvum0UgELIowRGR6cuwfNsR1OHrLamq-Rpg0F4XzFMSJHJ_xchPwFBBurNR45kOUYk2ueOKasd-xZ0g9Z14dg.webp", + "https://i.namu.wiki/i/PmFR4AjifhcmdLRXQUPPce9Z7BXVWc6mVX4N22fPUKOzK5ZfjNTfo9e1M7HPa2jiEmG_tuhm43aJMGWLylyQqw.svg"), + 푸우("https://i.namu.wiki/i/aj4JXZR4P-ZiY6Gf0EsoPMK3XFHHsSmx0b8ysKnUDpEd0ET1BVQHZIEAGvZGHCNJrn7Nkz7N5zeYzKh3WPSTGdCdOPpj1LnlAceeLWTmMSsiXvl2fyGaEZfRjm7i6DiBTW6_7pDqIRCWfYRQFKUsdg.webp", + "https://i.namu.wiki/i/_EIBF52MTqZAj5wtmY86jsU4fs7aYsdns4guDgLKYWQGoauVdCyZZiFcxc5qI92HxTUiWRRRrK0qk4Ot0qCRGpIc1GUTjUaYz1Y20IKkDuIo9472InXbpNMxcsE8PmP-taYj-7-Ql3_P557yYOk3EA.webp"), + 장범준("https://i.namu.wiki/i/6VAPyai_C0lBvsGytiMDu3moDOzS9UH9TDHqxzkjPWFymhQV-vcyH4q884nf1KKH2lVzLqMndBnCOTlUh4ZJE6QeB1oUQEH23d_FwMa3CFsyj8mkn3nG2DQMmJ31TN0cvCrxk6II8-IWq6C-d883zA.webp", + "https://i.namu.wiki/i/WPzIZvkNW_-q9UZmEHLKMtrvAQ2g2Oo7MwbrbzWnWEkRYYAdc2cyougS-n8-BwWsLuo3knt9aaHYEGiyhd7jtg.webp"), + 세븐틴("https://i.namu.wiki/i/55JrvWZKaTo_Vik4Wim6-PfXiEmWqwYnAwL5_KmNg9FWiM31U5VV-lmMl76TgtON5hpGP9dIEBhub70rAQvbvOVWt7dQ6GLqvrnpbQSV3Vr1vEKRXjk_RSqCpz5a_7nupUqhB8sBJExEDHf7WGKQDw.webp", + "https://i.namu.wiki/i/l4c_545au41xOK_9XRJyDh1PoNU1k9v-T5NF1UhLHxgCBQJzahV-ra8kP87FLmVFhey_OaWJcrBDJs0RmqbBB3lziiAgbM9lkDUAkENQBv4GweS2MSglXGGno9XQfnzisf5e-Z7185_U4jTqIqTiQA.svg"), + 아이들("https://i.namu.wiki/i/LTu_7r5vrTyZgjGb0pV79BoSI_CZr3hwLMnj2s1-ShPb-A07Nc0Gh_rGn8dic1_JwcJlB-pnSunyqmmIP-UhKRw33PlPO5GECFE2u4I5EtKIXN3c5u8_Wln6U22-Ofyjf90PxLLG1BLQziOoQ0d-pg.webp", + "https://i.namu.wiki/i/KwThwv_MdMM3O7mCr-WyXHXCZhfwKtLgAof5i-wIkkgp10izoSGyTKwCgMgBcoAaIP7VocBS-D36nHI6pkiPy3E8ncWJqsqrghC8bwoaM5dOEs32E9QSxk12CZUKCzGg9AM1bJivIuBBzFBpuc5JBg.webp"), + 에스파("https://i.namu.wiki/i/KJ5Gpz42djU6ZUsFKnkAnpMS1zRFxUOuqzt9plzbjV_mkFlruZcDULsfEjpVw-2vxjsSKbcGflPlOThHE1DgzST-hnm9jmxPqdPMExPkqH_71ZMF6jhQVfQX6QuNZw3Bz0EZ4C1sO5vpZ-OJNfvTyg.webp", + "https://i.namu.wiki/i/yAfAHme6H-HfWWQCvNAje-KInl_XM-xzRHOUmUxvRxh-HLbzk8KbG6zmD9qQXfUAeCenhHM5whZJ2nhQk0lanzT1LVja3BEQCVk1yPWABxy4NygdaLGyNpiRZTwVFkhD_PnCcESdUQ7-oEtK0YptsQ.webp"), + ; + + private final String profileImage; + private final String backgroundImageUrl; + + MockArtist(String profileImage, String backgroundImageUrl) { + this.profileImage = profileImage; + this.backgroundImageUrl = backgroundImageUrl; + } + + public String getProfileImage() { + return profileImage; + } + + public String getBackgroundImageUrl() { + return backgroundImageUrl; + } +} diff --git a/backend/src/main/java/com/festago/mock/domain/MockArtistsGenerator.java b/backend/src/main/java/com/festago/mock/domain/MockArtistsGenerator.java new file mode 100644 index 000000000..d66d92212 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/domain/MockArtistsGenerator.java @@ -0,0 +1,20 @@ +package com.festago.mock.domain; + +import com.festago.artist.domain.Artist; +import java.util.Arrays; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class MockArtistsGenerator { + + public List generate() { + return Arrays.stream(MockArtist.values()) + .map(this::createArtist) + .toList(); + } + + private Artist createArtist(MockArtist mockArtist) { + return new Artist(mockArtist.name(), mockArtist.getProfileImage(), mockArtist.getBackgroundImageUrl()); + } +} diff --git a/backend/src/main/java/com/festago/mock/domain/MockFestivalDurationGenerator.java b/backend/src/main/java/com/festago/mock/domain/MockFestivalDurationGenerator.java new file mode 100644 index 000000000..7b5dcc7f7 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/domain/MockFestivalDurationGenerator.java @@ -0,0 +1,9 @@ +package com.festago.mock.domain; + +import com.festago.festival.domain.FestivalDuration; +import java.time.LocalDate; + +public interface MockFestivalDurationGenerator { + + FestivalDuration generateFestivalDuration(LocalDate standardDate); +} diff --git a/backend/src/main/java/com/festago/mock/domain/MockFestivalsGenerator.java b/backend/src/main/java/com/festago/mock/domain/MockFestivalsGenerator.java new file mode 100644 index 000000000..227a726e6 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/domain/MockFestivalsGenerator.java @@ -0,0 +1,35 @@ +package com.festago.mock.domain; + +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalDuration; +import com.festago.school.domain.School; +import java.time.Clock; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MockFestivalsGenerator { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private final Clock clock; + private final MockFestivalDurationGenerator festivalDurationGenerator; + + public List generate(List schools) { + LocalDate now = LocalDate.now(clock); + return schools.stream() + .map(school -> { + FestivalDuration festivalDuration = festivalDurationGenerator.generateFestivalDuration(now); + return new Festival( + school.getName() + " " + festivalDuration.getStartDate().format(DATE_TIME_FORMATTER) + " 축제", + festivalDuration, + "", + school + ); + }) + .toList(); + } +} diff --git a/backend/src/main/java/com/festago/mock/domain/MockSchoolsGenerator.java b/backend/src/main/java/com/festago/mock/domain/MockSchoolsGenerator.java new file mode 100644 index 000000000..0d4c968ff --- /dev/null +++ b/backend/src/main/java/com/festago/mock/domain/MockSchoolsGenerator.java @@ -0,0 +1,41 @@ +package com.festago.mock.domain; + +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import java.util.Arrays; +import java.util.List; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.springframework.stereotype.Component; + +@Component +public class MockSchoolsGenerator { + + private static final int SCHOOL_PER_REGION = 3; + + public List generate() { + return Arrays.stream(SchoolRegion.values()) + .filter(it -> it != SchoolRegion.ANY) + .flatMap(this::crateSchools) + .toList(); + } + + private Stream crateSchools(SchoolRegion schoolRegion) { + return IntStream.rangeClosed(1, SCHOOL_PER_REGION) + .mapToObj(i -> { + String schoolName = String.format("%s%d대학교", schoolRegion.name(), i); + String schoolEmail = String.format("%s%d.com", schoolRegion.name(), i); + return crateSchool(schoolRegion, schoolName, schoolEmail); + }); + } + + private School crateSchool(SchoolRegion schoolRegion, String schoolName, String schoolEmail) { + return new School( + schoolEmail, + schoolName, + "", + "", + schoolRegion + ); + } +} diff --git a/backend/src/main/java/com/festago/mock/domain/MockStageArtistsGenerator.java b/backend/src/main/java/com/festago/mock/domain/MockStageArtistsGenerator.java new file mode 100644 index 000000000..74368170a --- /dev/null +++ b/backend/src/main/java/com/festago/mock/domain/MockStageArtistsGenerator.java @@ -0,0 +1,59 @@ +package com.festago.mock.domain; + +import com.festago.artist.domain.Artist; +import com.festago.common.exception.UnexpectedException; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.StageArtist; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import org.springframework.stereotype.Component; + +@Component +public class MockStageArtistsGenerator { + + private static final int STAGE_PER_ARTIST = 3; + + /** + * 영속되지 않은 상태의 StageArtist 목록을 생성합니다.
StageArtist는 Stage와 Artist의 식별자가 필요하므로, 인자로 들어오는 Stage, Artist는 영속된 상태여야 + * 합니다.
공연에 STAGE_PER_ARTIST 만큼 아티스트를 참여시키는게 불가능할 경우, 각 공연 별 최소 1명은 참가하는 것을 보장합니다.
따라서, stages.size() > + * artists.size() 이면 예외가 발생합니다.
생성된 StageArtist 목록에는 중복된 Artist가 존재하지 않습니다.
+ * + * @param stages 영속 상태의 아티스트가 참여될 공연 목록 + * @param artists 영속 상태의 공연에 참여할 아티스트 목록 + * @return 중복된 Artist가 없는 영속되지 않은 StageArtist 엔티티 리스트 + * @throws UnexpectedException 공연의 개수가 아티스트의 개수를 초과하면 + */ + public List generate(List stages, List artists) { + if (stages.size() > artists.size()) { + throw new UnexpectedException("공연의 개수는 아티스트의 개수를 초과할 수 없습니다."); + } + Queue artistQueue = createShuffledArtistQueue(new ArrayList<>(artists)); + List stageArtists = new ArrayList<>(); + for (int i = 0; i < STAGE_PER_ARTIST; i++) { + appendStageArtist(stageArtists, stages, artistQueue); + } + return stageArtists; + } + + private Queue createShuffledArtistQueue(List artists) { + Collections.shuffle(artists); + return new ArrayDeque<>(artists); + } + + private void appendStageArtist( + List stageArtists, + List stages, + Queue artistQueue + ) { + for (Stage stage : stages) { + if (artistQueue.isEmpty()) { + return; + } + Artist artist = artistQueue.poll(); + stageArtists.add(new StageArtist(stage.getId(), artist.getId())); + } + } +} diff --git a/backend/src/main/java/com/festago/mock/domain/MockStagesGenerator.java b/backend/src/main/java/com/festago/mock/domain/MockStagesGenerator.java new file mode 100644 index 000000000..ac4f9bab6 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/domain/MockStagesGenerator.java @@ -0,0 +1,28 @@ +package com.festago.mock.domain; + +import com.festago.festival.domain.Festival; +import com.festago.stage.domain.Stage; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class MockStagesGenerator { + + private static final long INCLUSIVE_VALUE = 1L; + private static final int STAGE_START_HOUR = 19; + + public List generate(Festival festival) { + LocalDate startDate = festival.getStartDate(); + LocalDate endDate = festival.getEndDate(); + return startDate.datesUntil(endDate.plusDays(INCLUSIVE_VALUE)) + .map(stageDate -> createStage(festival, stageDate)) + .toList(); + } + + private Stage createStage(Festival festival, LocalDate stageDate) { + LocalDateTime startTime = stageDate.atTime(STAGE_START_HOUR, 0); + return new Stage(startTime, startTime.minusWeeks(1), festival); + } +} diff --git a/backend/src/main/java/com/festago/mock/domain/RandomMockFestivalDurationGenerator.java b/backend/src/main/java/com/festago/mock/domain/RandomMockFestivalDurationGenerator.java new file mode 100644 index 000000000..4502767ca --- /dev/null +++ b/backend/src/main/java/com/festago/mock/domain/RandomMockFestivalDurationGenerator.java @@ -0,0 +1,25 @@ +package com.festago.mock.domain; + +import com.festago.festival.domain.FestivalDuration; +import java.time.LocalDate; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.stereotype.Component; + +@Component +public class RandomMockFestivalDurationGenerator implements MockFestivalDurationGenerator { + + private static final long DAYS_OF_WEEK = 7L; + private static final long INCLUSIVE_OFFSET = 1L; + private final Random random = ThreadLocalRandom.current(); + + @Override + public FestivalDuration generateFestivalDuration(LocalDate standardDate) { + long daysToAddStartDate = random.nextLong(DAYS_OF_WEEK + INCLUSIVE_OFFSET); // 0 ~ 7 + long daysToAddEndDate = random.nextLong( + DAYS_OF_WEEK + INCLUSIVE_OFFSET - daysToAddStartDate); // 0 ~ 7. if daysToAddStartDate == 7 ? 0 + LocalDate startDate = standardDate.plusDays(daysToAddStartDate); + LocalDate endDate = startDate.plusDays(daysToAddEndDate); + return new FestivalDuration(startDate, endDate); + } +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockArtistRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockArtistRepository.java new file mode 100644 index 000000000..15970e2c6 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockArtistRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.artist.domain.Artist; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockArtistRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockFestivalRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockFestivalRepository.java new file mode 100644 index 000000000..a18224397 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockFestivalRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.festival.domain.Festival; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockFestivalRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockSchoolRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockSchoolRepository.java new file mode 100644 index 000000000..312f55dbc --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockSchoolRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.school.domain.School; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockSchoolRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockStageArtistRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockStageArtistRepository.java new file mode 100644 index 000000000..cc666e420 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockStageArtistRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.stage.domain.StageArtist; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockStageArtistRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/mock/repository/ForMockStageRepository.java b/backend/src/main/java/com/festago/mock/repository/ForMockStageRepository.java new file mode 100644 index 000000000..e606c76d5 --- /dev/null +++ b/backend/src/main/java/com/festago/mock/repository/ForMockStageRepository.java @@ -0,0 +1,8 @@ +package com.festago.mock.repository; + +import com.festago.stage.domain.Stage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ForMockStageRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/festago/presentation/AdminController.java b/backend/src/main/java/com/festago/presentation/AdminController.java deleted file mode 100644 index adb63cff4..000000000 --- a/backend/src/main/java/com/festago/presentation/AdminController.java +++ /dev/null @@ -1,193 +0,0 @@ -package com.festago.presentation; - -import com.festago.admin.application.AdminService; -import com.festago.admin.dto.AdminResponse; -import com.festago.auth.annotation.Admin; -import com.festago.auth.application.AdminAuthService; -import com.festago.auth.dto.AdminLoginRequest; -import com.festago.auth.dto.AdminSignupRequest; -import com.festago.auth.dto.AdminSignupResponse; -import com.festago.auth.dto.RootAdminInitializeRequest; -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; -import com.festago.festival.application.FestivalService; -import com.festago.festival.dto.FestivalCreateRequest; -import com.festago.festival.dto.FestivalResponse; -import com.festago.festival.dto.FestivalUpdateRequest; -import com.festago.school.application.SchoolService; -import com.festago.school.dto.SchoolCreateRequest; -import com.festago.school.dto.SchoolResponse; -import com.festago.school.dto.SchoolUpdateRequest; -import com.festago.stage.application.StageService; -import com.festago.stage.dto.StageCreateRequest; -import com.festago.stage.dto.StageResponse; -import com.festago.stage.dto.StageUpdateRequest; -import com.festago.ticket.application.TicketService; -import com.festago.ticket.dto.TicketCreateRequest; -import com.festago.ticket.dto.TicketCreateResponse; -import io.swagger.v3.oas.annotations.Hidden; -import jakarta.validation.Valid; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.info.BuildProperties; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/admin/api") -@Hidden -@RequiredArgsConstructor -public class AdminController { - - private final FestivalService festivalService; - private final StageService stageService; - private final TicketService ticketService; - private final AdminService adminService; - private final AdminAuthService adminAuthService; - private final SchoolService schoolService; - private final Optional properties; - - @PostMapping("/festivals") - public ResponseEntity createFestival(@RequestBody @Valid FestivalCreateRequest request) { - FestivalResponse response = festivalService.create(request); - return ResponseEntity.ok() - .body(response); - } - - @PatchMapping("/festivals/{festivalId}") - public ResponseEntity updateFestival(@RequestBody @Valid FestivalUpdateRequest request, - @PathVariable Long festivalId) { - festivalService.update(festivalId, request); - return ResponseEntity.ok() - .build(); - } - - @DeleteMapping("/festivals/{festivalId}") - public ResponseEntity deleteFestival(@PathVariable Long festivalId) { - festivalService.delete(festivalId); - return ResponseEntity.ok() - .build(); - } - - @PostMapping("/stages") - public ResponseEntity createStage(@RequestBody @Valid StageCreateRequest request) { - StageResponse response = stageService.create(request); - return ResponseEntity.ok() - .body(response); - } - - @PatchMapping("/stages/{stageId}") - public ResponseEntity updateStage(@RequestBody @Valid StageUpdateRequest request, - @PathVariable Long stageId) { - stageService.update(stageId, request); - return ResponseEntity.ok() - .build(); - } - - @DeleteMapping("/stages/{stageId}") - public ResponseEntity deleteStage(@PathVariable Long stageId) { - stageService.delete(stageId); - return ResponseEntity.ok() - .build(); - } - - @PostMapping("/tickets") - public ResponseEntity createTicket(@RequestBody @Valid TicketCreateRequest request) { - TicketCreateResponse response = ticketService.create(request); - return ResponseEntity.ok() - .body(response); - } - - @PostMapping("/schools") - public ResponseEntity createSchool(@RequestBody @Valid SchoolCreateRequest request) { - SchoolResponse response = schoolService.create(request); - return ResponseEntity.ok() - .body(response); - } - - @PatchMapping("/schools/{schoolId}") - public ResponseEntity updateSchool(@RequestBody @Valid SchoolUpdateRequest request, - @PathVariable Long schoolId) { - schoolService.update(schoolId, request); - return ResponseEntity.ok() - .build(); - } - - @DeleteMapping("/schools/{schoolId}") - public ResponseEntity deleteSchool(@PathVariable Long schoolId) { - schoolService.delete(schoolId); - return ResponseEntity.ok() - .build(); - } - - @PostMapping("/login") - public ResponseEntity login(@RequestBody @Valid AdminLoginRequest request) { - String token = adminAuthService.login(request); - return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, getCookie(token)) - .build(); - } - - private String getCookie(String token) { - return ResponseCookie.from("token", token) - .httpOnly(true) - .secure(true) - .path("/") - .build().toString(); - } - - @GetMapping("/data") - public ResponseEntity adminData() { - AdminResponse response = adminService.getAdminResponse(); - return ResponseEntity.ok() - .body(response); - } - - @GetMapping("/version") - public ResponseEntity getVersion() { - return properties.map(it -> ResponseEntity.ok(it.getTime().atZone(ZoneId.of("Asia/Seoul")).toString())) - .orElseGet(() -> ResponseEntity.ok() - .body(LocalDateTime.now().toString())); - } - - @GetMapping("/error") - public ResponseEntity getError() { - throw new IllegalArgumentException("테스트용 에러입니다."); - } - - @GetMapping("/warn") - public ResponseEntity getWarn() { - throw new InternalServerException(ErrorCode.FOR_TEST_ERROR); - } - - @GetMapping("/info") - public ResponseEntity getInfo() { - throw new BadRequestException(ErrorCode.FOR_TEST_ERROR); - } - - @PostMapping("/initialize") - public ResponseEntity initializeRootAdmin(@RequestBody @Valid RootAdminInitializeRequest request) { - adminAuthService.initializeRootAdmin(request.password()); - return ResponseEntity.ok() - .build(); - } - - @PostMapping("/signup") - public ResponseEntity signupAdminAccount(@RequestBody @Valid AdminSignupRequest request, - @Admin Long adminId) { - AdminSignupResponse response = adminAuthService.signup(adminId, request); - return ResponseEntity.ok() - .body(response); - } -} diff --git a/backend/src/main/java/com/festago/presentation/AdminViewController.java b/backend/src/main/java/com/festago/presentation/AdminViewController.java deleted file mode 100644 index 994e2e614..000000000 --- a/backend/src/main/java/com/festago/presentation/AdminViewController.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.festago.presentation; - -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.UnauthorizedException; -import io.swagger.v3.oas.annotations.Hidden; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.ExceptionHandler; -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.servlet.View; -import org.springframework.web.servlet.view.InternalResourceView; -import org.springframework.web.servlet.view.RedirectView; - -@Controller -@RequestMapping("/admin") -@Hidden -public class AdminViewController { - - @GetMapping - public String adminPage() { - return "admin/admin-page"; - } - - @GetMapping("/login") - public String loginPage() { - return "admin/login"; - } - - @GetMapping("/signup") - public String signupPage() { - return "admin/signup"; - } - - @GetMapping("/festivals") - public String manageFestivalPage() { - return "admin/festival/manage-festival"; - } - - @GetMapping("/festivals/{festivalId}") - public String manageFestivalDetailPage(@PathVariable String festivalId) { - return "admin/festival/manage-festival-detail"; - } - - @GetMapping("/schools") - public String manageSchoolPage() { - return "admin/school/manage-school"; - } - - @GetMapping("/schools/{schoolId}") - public String manageSchoolDetailPage(@PathVariable String schoolId) { - return "admin/school/manage-school-detail"; - } - - @GetMapping("/stages/{stageId}") - public String manageStagePage(@PathVariable String stageId) { - return "admin/stage/manage-stage-detail"; - } - - @ExceptionHandler(UnauthorizedException.class) - public View handle(UnauthorizedException e, HttpServletResponse response) { - - if (e.getErrorCode() == ErrorCode.EXPIRED_AUTH_TOKEN) { - return new RedirectView("/admin/login"); - } - response.setStatus(HttpServletResponse.SC_NOT_FOUND); - return new InternalResourceView("/error/404"); - } -} diff --git a/backend/src/main/java/com/festago/presentation/AuthController.java b/backend/src/main/java/com/festago/presentation/AuthController.java deleted file mode 100644 index c08773431..000000000 --- a/backend/src/main/java/com/festago/presentation/AuthController.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.festago.presentation; - -import com.festago.auth.annotation.Member; -import com.festago.auth.application.AuthFacadeService; -import com.festago.auth.dto.LoginRequest; -import com.festago.auth.dto.LoginResponse; -import com.festago.fcm.application.MemberFCMService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -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; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/auth") -@Tag(name = "로그인 관련 요청") -@RequiredArgsConstructor -public class AuthController { - - private final AuthFacadeService authFacadeService; - private final MemberFCMService memberFCMService; - - @PostMapping("/oauth2") - @Operation(description = "소셜 엑세스 토큰을 기반으로 로그인 요청을 보낸다.", summary = "OAuth2 로그인") - public ResponseEntity login(@RequestBody @Valid LoginRequest request) { - LoginResponse response = authFacadeService.login(request.socialType(), request.accessToken()); - registerFCM(response, request); - return ResponseEntity.ok() - .body(response); - } - - private void registerFCM(LoginResponse response, LoginRequest request) { - String accessToken = response.accessToken(); - String fcmToken = request.fcmToken(); - memberFCMService.saveMemberFCM(response.isNew(), accessToken, fcmToken); - } - - @DeleteMapping - @SecurityRequirement(name = "bearerAuth") - @Operation(description = "회원 탈퇴 요청을 보낸다.", summary = "유저 회원 탈퇴") - public ResponseEntity deleteMember(@Member Long memberId) { - authFacadeService.deleteMember(memberId); - memberFCMService.deleteMemberFCM(memberId); - return ResponseEntity.ok() - .build(); - } -} diff --git a/backend/src/main/java/com/festago/presentation/FestivalController.java b/backend/src/main/java/com/festago/presentation/FestivalController.java deleted file mode 100644 index 67b9c94fd..000000000 --- a/backend/src/main/java/com/festago/presentation/FestivalController.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.festago.presentation; - -import com.festago.festival.application.FestivalService; -import com.festago.festival.dto.FestivalDetailResponse; -import com.festago.festival.dto.FestivalsResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/festivals") -@Tag(name = "축제 정보 요청") -@RequiredArgsConstructor -public class FestivalController { - - private final FestivalService festivalService; - - @GetMapping - @Operation(description = "모든 축제들을 조회한다.", summary = "축제 목록 조회") - public ResponseEntity findAll() { - FestivalsResponse response = festivalService.findAll(); - return ResponseEntity.ok() - .body(response); - } - - @GetMapping("/{festivalId}") - @Operation(description = "해당 Id 의 축제를 조회한다.", summary = "축제 상세 정보 조회") - public ResponseEntity findDetail(@PathVariable Long festivalId) { - FestivalDetailResponse response = festivalService.findDetail(festivalId); - return ResponseEntity.ok() - .body(response); - } -} diff --git a/backend/src/main/java/com/festago/presentation/GlobalExceptionHandler.java b/backend/src/main/java/com/festago/presentation/GlobalExceptionHandler.java deleted file mode 100644 index 8c13f7a10..000000000 --- a/backend/src/main/java/com/festago/presentation/GlobalExceptionHandler.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.festago.presentation; - -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.FestaGoException; -import com.festago.common.exception.ForbiddenException; -import com.festago.common.exception.InternalServerException; -import com.festago.common.exception.NotFoundException; -import com.festago.common.exception.TooManyRequestException; -import com.festago.common.exception.UnauthorizedException; -import com.festago.common.exception.dto.ErrorResponse; -import com.festago.presentation.auth.AuthenticateContext; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.InvalidMediaTypeException; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.context.request.WebRequest; -import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; - -@RestControllerAdvice -@RequiredArgsConstructor -public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { - - private static final String LOG_FORMAT_INFO = "\n[🔵INFO] - ({} {})\n(id: {}, role: {})\n{}\n {}: {}"; - private static final String LOG_FORMAT_WARN = "\n[🟠WARN] - ({} {})\n(id: {}, role: {})\n{}\n {}: {}"; - private static final String LOG_FORMAT_ERROR = "\n[🔴ERROR] - ({} {})\n(id: {}, role: {})"; - // INFO - /* - [🔵INFO] - (POST /admin/info) - (id: 1, role: MEMBER) - FOR_TEST_ERROR - com.festago.exception.BadRequestException: 테스트용 에러입니다. - */ - - // WARN - /* - [🟠WARN] - (POST /admin/warn) - (id: 1, role: MEMBER) - FOR_TEST_ERROR - com.festago.exception.InternalServerException: 테스트용 에러입니다. - */ - - // ERROR - /* - [🔴ERROR] - (POST /admin/error) - (id: 1, role: MEMBER) - java.lang.IllegalArgumentException: 테스트용 에러입니다. - at com.festago.presentation.AdminController.getError(AdminController.java:129) - */ - - private final AuthenticateContext authenticateContext; - private final Logger errorLogger; - - @ExceptionHandler(InvalidMediaTypeException.class) - public ResponseEntity handle(InvalidMediaTypeException e) { - return ResponseEntity.badRequest().build(); - } - - @ExceptionHandler(BadRequestException.class) - public ResponseEntity handle(BadRequestException e, HttpServletRequest request) { - logInfo(e, request); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.from(e)); - } - - @ExceptionHandler(UnauthorizedException.class) - public ResponseEntity handle(UnauthorizedException e, HttpServletRequest request) { - logInfo(e, request); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ErrorResponse.from(e)); - } - - @ExceptionHandler(ForbiddenException.class) - public ResponseEntity handle(ForbiddenException e, HttpServletRequest request) { - logInfo(e, request); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse.from(e)); - } - - @ExceptionHandler(NotFoundException.class) - public ResponseEntity handle(NotFoundException e, HttpServletRequest request) { - logInfo(e, request); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse.from(e)); - } - - @ExceptionHandler(TooManyRequestException.class) - public ResponseEntity handle(TooManyRequestException e, HttpServletRequest request) { - logInfo(e, request); - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(ErrorResponse.from(e)); - } - - @ExceptionHandler(InternalServerException.class) - public ResponseEntity handle(InternalServerException e, HttpServletRequest request) { - logWarn(e, request); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR)); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handle(Exception e, HttpServletRequest request) { - logError(e, request); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR)); - } - - @Override - protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, - HttpHeaders headers, - HttpStatusCode status, WebRequest request) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ErrorResponse.from(ErrorCode.INVALID_REQUEST_ARGUMENT, e)); - } - - private void logInfo(FestaGoException e, HttpServletRequest request) { - if (errorLogger.isInfoEnabled()) { - errorLogger.info(LOG_FORMAT_INFO, request.getMethod(), request.getRequestURI(), authenticateContext.getId(), - authenticateContext.getRole(), e.getErrorCode(), e.getClass().getName(), e.getMessage()); - } - } - - private void logWarn(FestaGoException e, HttpServletRequest request) { - if (errorLogger.isWarnEnabled()) { - errorLogger.warn(LOG_FORMAT_WARN, request.getMethod(), request.getRequestURI(), authenticateContext.getId(), - authenticateContext.getRole(), e.getErrorCode(), e.getClass().getName(), e.getMessage()); - } - } - - private void logError(Exception e, HttpServletRequest request) { - if (errorLogger.isErrorEnabled()) { - errorLogger.error(LOG_FORMAT_ERROR, request.getMethod(), request.getRequestURI(), - authenticateContext.getId(), authenticateContext.getRole(), e); - } - } -} diff --git a/backend/src/main/java/com/festago/presentation/SchoolController.java b/backend/src/main/java/com/festago/presentation/SchoolController.java deleted file mode 100644 index 8667e4597..000000000 --- a/backend/src/main/java/com/festago/presentation/SchoolController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.festago.presentation; - -import com.festago.school.application.SchoolService; -import com.festago.school.dto.SchoolResponse; -import com.festago.school.dto.SchoolsResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/schools") -@RequiredArgsConstructor -public class SchoolController { - - private final SchoolService schoolService; - - @GetMapping - public ResponseEntity findAll() { - return ResponseEntity.ok() - .body(schoolService.findAll()); - } - - @GetMapping("/{schoolId}") - public ResponseEntity findById(@PathVariable Long schoolId) { - return ResponseEntity.ok() - .body(schoolService.findById(schoolId)); - } -} diff --git a/backend/src/main/java/com/festago/presentation/StageController.java b/backend/src/main/java/com/festago/presentation/StageController.java deleted file mode 100644 index d94a407b9..000000000 --- a/backend/src/main/java/com/festago/presentation/StageController.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.festago.presentation; - -import com.festago.stage.application.StageService; -import com.festago.stage.dto.StageResponse; -import com.festago.ticket.application.TicketService; -import com.festago.ticket.dto.StageTicketsResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/stages") -@Tag(name = "공연 정보 요청") -@RequiredArgsConstructor -public class StageController { - - private final TicketService ticketService; - private final StageService stageService; - - @GetMapping("/{stageId}/tickets") - @Operation(description = "특정 무대의 티켓 정보를 보여준다.", summary = "무대 티켓 목록 조회") - public ResponseEntity findStageTickets(@PathVariable Long stageId) { - StageTicketsResponse response = ticketService.findStageTickets(stageId); - return ResponseEntity.ok() - .body(response); - } - - @GetMapping("/{stageId}") - @Operation(description = "특정 무대의 정보를 보여준다.", summary = "무대 정보 조회") - public ResponseEntity findStageDetail(@PathVariable Long stageId) { - StageResponse response = stageService.findDetail(stageId); - return ResponseEntity.ok() - .body(response); - } -} diff --git a/backend/src/main/java/com/festago/presentation/StudentController.java b/backend/src/main/java/com/festago/presentation/StudentController.java deleted file mode 100644 index 96c19f53d..000000000 --- a/backend/src/main/java/com/festago/presentation/StudentController.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.festago.presentation; - -import com.festago.auth.annotation.Member; -import com.festago.student.application.StudentService; -import com.festago.student.dto.StudentSendMailRequest; -import com.festago.student.dto.StudentVerificateRequest; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -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 -@RequestMapping("/students") -@Tag(name = "학생 요청") -@RequiredArgsConstructor -public class StudentController { - - private final StudentService studentService; - - @PostMapping("/send-verification") - @Operation(description = "학교 인증 이메일을 전송한다.", summary = "학생 인증 이메일 전송") - public ResponseEntity sendEmail(@Member Long memberId, - @RequestBody @Valid StudentSendMailRequest request) { - studentService.sendVerificationMail(memberId, request); - return ResponseEntity.ok() - .build(); - } - - @PostMapping("/verification") - @Operation(description = "학교 인증을 수행한다.", summary = "학생 인증 수행") - public ResponseEntity verificate(@Member Long memberId, - @RequestBody @Valid StudentVerificateRequest request) { - studentService.verificate(memberId, request); - return ResponseEntity.ok() - .build(); - } -} diff --git a/backend/src/main/java/com/festago/presentation/auth/AuthInterceptor.java b/backend/src/main/java/com/festago/presentation/auth/AuthInterceptor.java deleted file mode 100644 index 8d4399183..000000000 --- a/backend/src/main/java/com/festago/presentation/auth/AuthInterceptor.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.festago.presentation.auth; - -import com.festago.auth.application.AuthExtractor; -import com.festago.auth.application.TokenExtractor; -import com.festago.auth.domain.AuthPayload; -import com.festago.auth.domain.Role; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.ForbiddenException; -import com.festago.common.exception.UnauthorizedException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.util.Assert; -import org.springframework.web.servlet.HandlerInterceptor; - -public class AuthInterceptor implements HandlerInterceptor { - - private final AuthExtractor authExtractor; - private final TokenExtractor tokenExtractor; - private final AuthenticateContext authenticateContext; - private final Role role; - - private AuthInterceptor(AuthExtractor authExtractor, TokenExtractor tokenExtractor, - AuthenticateContext authenticateContext, Role role) { - Assert.notNull(authExtractor, "The authExtractor must not be null"); - Assert.notNull(tokenExtractor, "The tokenExtractor must not be null"); - Assert.notNull(authenticateContext, "The authenticateContext must not be null"); - Assert.notNull(role, "The role must not be null"); - this.authExtractor = authExtractor; - this.tokenExtractor = tokenExtractor; - this.authenticateContext = authenticateContext; - this.role = role; - } - - public static AuthInterceptorBuilder builder() { - return new AuthInterceptorBuilder(); - } - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) - throws Exception { - String token = tokenExtractor.extract(request) - .orElseThrow(() -> new UnauthorizedException(ErrorCode.NEED_AUTH_TOKEN)); - AuthPayload payload = authExtractor.extract(token); - if (payload.getRole() != this.role) { - throw new ForbiddenException(ErrorCode.NOT_ENOUGH_PERMISSION); - } - authenticateContext.setAuthenticate(payload.getMemberId(), payload.getRole()); - return true; - } - - public static class AuthInterceptorBuilder { - - private AuthExtractor authExtractor; - private TokenExtractor tokenExtractor; - private AuthenticateContext authenticateContext; - private Role role; - - public AuthInterceptorBuilder authExtractor(AuthExtractor authExtractor) { - this.authExtractor = authExtractor; - return this; - } - - public AuthInterceptorBuilder authenticateContext(AuthenticateContext authenticateContext) { - this.authenticateContext = authenticateContext; - return this; - } - - public AuthInterceptorBuilder tokenExtractor(TokenExtractor tokenExtractor) { - this.tokenExtractor = tokenExtractor; - return this; - } - - public AuthInterceptorBuilder role(Role role) { - this.role = role; - return this; - } - - public AuthInterceptor build() { - return new AuthInterceptor(authExtractor, tokenExtractor, authenticateContext, role); - } - } -} diff --git a/backend/src/main/java/com/festago/presentation/auth/AuthenticateContext.java b/backend/src/main/java/com/festago/presentation/auth/AuthenticateContext.java deleted file mode 100644 index 5334bf2be..000000000 --- a/backend/src/main/java/com/festago/presentation/auth/AuthenticateContext.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.festago.presentation.auth; - -import com.festago.auth.domain.Role; -import org.springframework.stereotype.Component; -import org.springframework.web.context.annotation.RequestScope; - -@Component -@RequestScope -public class AuthenticateContext { - - private Long id; - private Role role = Role.ANONYMOUS; - - public void setAuthenticate(Long id, Role role) { - this.id = id; - this.role = role; - } - - public Long getId() { - return id; - } - - public Role getRole() { - return role; - } -} diff --git a/backend/src/main/java/com/festago/presentation/common/ErrorFilter.java b/backend/src/main/java/com/festago/presentation/common/ErrorFilter.java deleted file mode 100644 index c3a2a1218..000000000 --- a/backend/src/main/java/com/festago/presentation/common/ErrorFilter.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.festago.presentation.common; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Objects; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -@Component -public class ErrorFilter extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - try { - doFilter(request, response, filterChain); - } catch (Exception e) { - response.setStatus(HttpServletResponse.SC_NOT_FOUND); - if (Objects.equals(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) { - return; - } - request.getRequestDispatcher("/error/404").forward(request, response); - } - } -} diff --git a/backend/src/main/java/com/festago/school/application/SchoolCommandService.java b/backend/src/main/java/com/festago/school/application/SchoolCommandService.java new file mode 100644 index 000000000..afe34c449 --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/SchoolCommandService.java @@ -0,0 +1,59 @@ +package com.festago.school.application; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.school.domain.School; +import com.festago.school.dto.command.SchoolCreateCommand; +import com.festago.school.dto.command.SchoolUpdateCommand; +import com.festago.school.dto.event.SchoolCreatedEvent; +import com.festago.school.dto.event.SchoolUpdatedEvent; +import com.festago.school.repository.SchoolRepository; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class SchoolCommandService { + + private final SchoolRepository schoolRepository; + private final ApplicationEventPublisher eventPublisher; + + public Long createSchool(SchoolCreateCommand command) { + validateCreate(command); + School school = schoolRepository.save(command.toEntity()); + eventPublisher.publishEvent(new SchoolCreatedEvent(school)); + return school.getId(); + } + + private void validateCreate(SchoolCreateCommand command) { + String name = command.name(); + if (schoolRepository.existsByName(name)) { + throw new BadRequestException(ErrorCode.DUPLICATE_SCHOOL_NAME); + } + } + + /** + * TODO 학교를 인증한 학생이 있다면, domain을 변경하는 것은 문제가 될 수 있지 않을까? + */ + public void updateSchool(Long schoolId, SchoolUpdateCommand command) { + School school = schoolRepository.getOrThrow(schoolId); + validateUpdate(school, command); + school.changeName(command.name()); + school.changeDomain(command.domain()); + school.changeRegion(command.region()); + school.changeLogoUrl(command.logoUrl()); + school.changeBackgroundImageUrl(command.backgroundImageUrl()); + eventPublisher.publishEvent(new SchoolUpdatedEvent(school)); + } + + private void validateUpdate(School school, SchoolUpdateCommand command) { + String name = command.name(); + if (!Objects.equals(school.getName(), name) && schoolRepository.existsByName(name)) { + throw new BadRequestException(ErrorCode.DUPLICATE_SCHOOL_NAME); + } + } +} diff --git a/backend/src/main/java/com/festago/school/application/SchoolDeleteService.java b/backend/src/main/java/com/festago/school/application/SchoolDeleteService.java new file mode 100644 index 000000000..db06eea83 --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/SchoolDeleteService.java @@ -0,0 +1,26 @@ +package com.festago.school.application; + +import com.festago.school.domain.validator.SchoolDeleteValidator; +import com.festago.school.dto.event.SchoolDeletedEvent; +import com.festago.school.repository.SchoolRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class SchoolDeleteService { + + private final SchoolRepository schoolRepository; + private final List validators; + private final ApplicationEventPublisher eventPublisher; + + public void deleteSchool(Long schoolId) { + validators.forEach(validator -> validator.validate(schoolId)); + schoolRepository.deleteById(schoolId); + eventPublisher.publishEvent(new SchoolDeletedEvent(schoolId)); + } +} diff --git a/backend/src/main/java/com/festago/school/application/SchoolService.java b/backend/src/main/java/com/festago/school/application/SchoolService.java deleted file mode 100644 index f8fbe2792..000000000 --- a/backend/src/main/java/com/festago/school/application/SchoolService.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.festago.school.application; - -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.NotFoundException; -import com.festago.school.domain.School; -import com.festago.school.dto.SchoolCreateRequest; -import com.festago.school.dto.SchoolResponse; -import com.festago.school.dto.SchoolUpdateRequest; -import com.festago.school.dto.SchoolsResponse; -import com.festago.school.repository.SchoolRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional -@RequiredArgsConstructor -public class SchoolService { - - private final SchoolRepository schoolRepository; - - @Transactional(readOnly = true) - public SchoolsResponse findAll() { - return SchoolsResponse.from(schoolRepository.findAll()); - } - - @Transactional(readOnly = true) - public SchoolResponse findById(Long id) { - return SchoolResponse.from(findSchool(id)); - } - - private School findSchool(Long id) { - return schoolRepository.findById(id) - .orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)); - } - - public SchoolResponse create(SchoolCreateRequest request) { - validateSchool(request); - String domain = request.domain(); - String name = request.name(); - School school = schoolRepository.save(new School(domain, name)); - return SchoolResponse.from(school); - } - - private void validateSchool(SchoolCreateRequest request) { - if (schoolRepository.existsByDomainOrName(request.domain(), request.name())) { - throw new BadRequestException(ErrorCode.DUPLICATE_SCHOOL); - } - } - - public void update(Long schoolId, SchoolUpdateRequest request) { - School school = findSchool(schoolId); - school.changeName(request.name()); - school.changeDomain(request.domain()); - } - - public void delete(Long schoolId) { - // TODO 지금은 외래키 제약조건 때문에 참조하는 다른 엔티티가 있으면 예외가 발생하지만, 추후 이미 가입된 학생이 있다는 등 예외가 필요할듯 - try { - schoolRepository.deleteById(schoolId); - schoolRepository.flush(); - } catch (DataIntegrityViolationException e) { - throw new BadRequestException(ErrorCode.DELETE_CONSTRAINT_SCHOOL); - } - } -} diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolSearchV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/SchoolSearchV1QueryService.java new file mode 100644 index 000000000..a9dd4a8c2 --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolSearchV1QueryService.java @@ -0,0 +1,20 @@ +package com.festago.school.application.v1; + +import com.festago.school.dto.v1.SchoolSearchV1Response; +import com.festago.school.repository.v1.SchoolSearchV1QueryDslRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class SchoolSearchV1QueryService { + + private final SchoolSearchV1QueryDslRepository schoolSearchV1QueryDslRepository; + + public List searchSchools(String keyword) { + return schoolSearchV1QueryDslRepository.searchSchools(keyword); + } +} diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolTotalSearchV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/SchoolTotalSearchV1QueryService.java new file mode 100644 index 000000000..9acf5680c --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolTotalSearchV1QueryService.java @@ -0,0 +1,39 @@ +package com.festago.school.application.v1; + +import com.festago.school.dto.v1.SchoolSearchV1Response; +import com.festago.school.dto.v1.SchoolTotalSearchV1Response; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class SchoolTotalSearchV1QueryService { + + private final SchoolSearchV1QueryService schoolSearchV1QueryService; + private final SchoolUpcomingFestivalStartDateV1QueryService schoolUpcomingFestivalStartDateV1QueryService; + + public List searchSchools(String keyword) { + List schoolSearchResponses = schoolSearchV1QueryService.searchSchools(keyword); + List schoolIds = schoolSearchResponses.stream() + .map(SchoolSearchV1Response::id) + .toList(); + Map schoolIdToUpcomingFestivalStartDate = getSchoolIdToUpcomingFestivalStartDate(schoolIds); + return schoolSearchResponses.stream() + .map(schoolSearchResponse -> new SchoolTotalSearchV1Response( + schoolSearchResponse.id(), + schoolSearchResponse.name(), + schoolSearchResponse.logoUrl(), + schoolIdToUpcomingFestivalStartDate.get(schoolSearchResponse.id()) + )) + .toList(); + } + + private Map getSchoolIdToUpcomingFestivalStartDate(List schoolIds) { + return schoolUpcomingFestivalStartDateV1QueryService.getSchoolIdToUpcomingFestivalStartDate(schoolIds); + } +} diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolUpcomingFestivalStartDateV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/SchoolUpcomingFestivalStartDateV1QueryService.java new file mode 100644 index 000000000..701ff0b66 --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolUpcomingFestivalStartDateV1QueryService.java @@ -0,0 +1,10 @@ +package com.festago.school.application.v1; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public interface SchoolUpcomingFestivalStartDateV1QueryService { + + Map getSchoolIdToUpcomingFestivalStartDate(List schoolIds); +} diff --git a/backend/src/main/java/com/festago/school/application/v1/SchoolV1QueryService.java b/backend/src/main/java/com/festago/school/application/v1/SchoolV1QueryService.java new file mode 100644 index 000000000..3b6b93c3b --- /dev/null +++ b/backend/src/main/java/com/festago/school/application/v1/SchoolV1QueryService.java @@ -0,0 +1,34 @@ +package com.festago.school.application.v1; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.school.repository.v1.SchoolFestivalV1SearchCondition; +import com.festago.school.repository.v1.SchoolV1QueryDslRepository; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class SchoolV1QueryService { + + private final SchoolV1QueryDslRepository schoolV1QueryDslRepository; + + public SchoolDetailV1Response findDetailById(Long schoolId) { + return schoolV1QueryDslRepository.findDetailById(schoolId) + .orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)); + } + + public Slice findFestivalsBySchoolId( + Long schoolId, + LocalDate today, + SchoolFestivalV1SearchCondition searchCondition + ) { + return schoolV1QueryDslRepository.findFestivalsBySchoolId(schoolId, today, searchCondition); + } +} diff --git a/backend/src/main/java/com/festago/school/domain/School.java b/backend/src/main/java/com/festago/school/domain/School.java index a261c838e..91a4f8040 100644 --- a/backend/src/main/java/com/festago/school/domain/School.java +++ b/backend/src/main/java/com/festago/school/domain/School.java @@ -1,9 +1,12 @@ package com.festago.school.domain; import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.util.ImageUrlHelper; import com.festago.common.util.Validator; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -11,50 +14,77 @@ import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.NoArgsConstructor; -import org.springframework.util.Assert; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) public class School extends BaseTimeEntity { + private static final int MAX_DOMAIN_LENGTH = 50; + private static final int MAX_NAME_LENGTH = 255; + private static final int MAX_IMAGE_URL_LENGTH = 255; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotNull - @Size(max = 50) - @Column(unique = true) + @Size(max = MAX_DOMAIN_LENGTH) private String domain; @NotNull - @Size(max = 255) + @Size(max = MAX_NAME_LENGTH) @Column(unique = true) private String name; - public School(String domain, String name) { - this(null, domain, name); + private String logoUrl; + + @Column(name = "background_image_url") + private String backgroundUrl; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") + private SchoolRegion region; + + public School(String domain, String name, String logoUrl, String backgroundUrl, SchoolRegion region) { + this(null, domain, name, logoUrl, backgroundUrl, region); } - public School(Long id, String domain, String name) { - validate(domain, name); + public School(Long id, String domain, String name, String logoUrl, String backgroundImageUrl, SchoolRegion region) { + validate(domain, name, region, logoUrl, backgroundImageUrl); this.id = id; this.domain = domain; this.name = name; + this.logoUrl = ImageUrlHelper.getBlankStringIfBlank(logoUrl); + this.backgroundUrl = ImageUrlHelper.getBlankStringIfBlank(backgroundImageUrl); + this.region = region; } - private void validate(String domain, String name) { + private void validate(String domain, String name, SchoolRegion region, String logoUrl, String backgroundImageUrl) { validateDomain(domain); validateName(name); + validateRegion(region); + validateImageUrl(logoUrl, "logoUrl"); + validateImageUrl(backgroundImageUrl, "backgroundImageUrl"); } private void validateDomain(String domain) { - Assert.notNull(domain, "domain은 null 값이 될 수 없습니다."); - Validator.maxLength(domain, 50, "domain은 50글자를 넘을 수 없습니다."); + String fieldName = "domain"; + Validator.notBlank(domain, fieldName); + Validator.maxLength(domain, MAX_DOMAIN_LENGTH, fieldName); } private void validateName(String name) { - Assert.notNull(name, "name은 null 값이 될 수 없습니다."); - Validator.maxLength(name, 255, "name은 255글자를 넘을 수 없습니다."); + String fieldName = "name"; + Validator.notBlank(name, fieldName); + Validator.maxLength(name, MAX_NAME_LENGTH, fieldName); + } + + private void validateRegion(SchoolRegion region) { + Validator.notNull(region, "region"); + } + + private void validateImageUrl(String logoUrl, String fieldName) { + Validator.maxLength(logoUrl, MAX_IMAGE_URL_LENGTH, fieldName); } public void changeDomain(String domain) { @@ -67,6 +97,21 @@ public void changeName(String name) { this.name = name; } + public void changeRegion(SchoolRegion region) { + validateRegion(region); + this.region = region; + } + + public void changeLogoUrl(String logoUrl) { + validateImageUrl(logoUrl, "logoUrl"); + this.logoUrl = ImageUrlHelper.getBlankStringIfBlank(logoUrl); + } + + public void changeBackgroundImageUrl(String backgroundImageUrl) { + validateImageUrl(backgroundImageUrl, "backgroundImageUrl"); + this.backgroundUrl = ImageUrlHelper.getBlankStringIfBlank(backgroundImageUrl); + } + public Long getId() { return id; } @@ -78,4 +123,16 @@ public String getDomain() { public String getName() { return name; } + + public String getLogoUrl() { + return logoUrl; + } + + public String getBackgroundUrl() { + return backgroundUrl; + } + + public SchoolRegion getRegion() { + return region; + } } diff --git a/backend/src/main/java/com/festago/school/domain/SchoolRegion.java b/backend/src/main/java/com/festago/school/domain/SchoolRegion.java new file mode 100644 index 000000000..e0f7602e3 --- /dev/null +++ b/backend/src/main/java/com/festago/school/domain/SchoolRegion.java @@ -0,0 +1,23 @@ +package com.festago.school.domain; + +public enum SchoolRegion { + 서울, + 부산, + 대구, + 인천, + 광주, + 대전, + 울산, + 세종, + 경기, + 강원, + 충북, + 충남, + 전북, + 전남, + 경북, + 경남, + 제주, + ANY, + ; +} diff --git a/backend/src/main/java/com/festago/school/domain/validator/SchoolDeleteValidator.java b/backend/src/main/java/com/festago/school/domain/validator/SchoolDeleteValidator.java new file mode 100644 index 000000000..4c681c4be --- /dev/null +++ b/backend/src/main/java/com/festago/school/domain/validator/SchoolDeleteValidator.java @@ -0,0 +1,6 @@ +package com.festago.school.domain.validator; + +public interface SchoolDeleteValidator { + + void validate(Long schoolId); +} diff --git a/backend/src/main/java/com/festago/school/dto/SchoolCreateRequest.java b/backend/src/main/java/com/festago/school/dto/SchoolCreateRequest.java deleted file mode 100644 index cef320d8e..000000000 --- a/backend/src/main/java/com/festago/school/dto/SchoolCreateRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.festago.school.dto; - -import jakarta.validation.constraints.NotBlank; - -public record SchoolCreateRequest( - @NotBlank(message = "name은 공백일 수 없습니다.") - String name, - @NotBlank(message = "domain은 공백일 수 없습니다.") - String domain -) { - -} diff --git a/backend/src/main/java/com/festago/school/dto/SchoolResponse.java b/backend/src/main/java/com/festago/school/dto/SchoolResponse.java deleted file mode 100644 index e75e78900..000000000 --- a/backend/src/main/java/com/festago/school/dto/SchoolResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.festago.school.dto; - -import com.festago.school.domain.School; - -public record SchoolResponse( - Long id, - String domain, - String name) { - - public static SchoolResponse from(School school) { - return new SchoolResponse( - school.getId(), - school.getDomain(), - school.getName() - ); - } -} diff --git a/backend/src/main/java/com/festago/school/dto/SchoolUpdateRequest.java b/backend/src/main/java/com/festago/school/dto/SchoolUpdateRequest.java deleted file mode 100644 index a94bc101b..000000000 --- a/backend/src/main/java/com/festago/school/dto/SchoolUpdateRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.festago.school.dto; - -import jakarta.validation.constraints.NotBlank; - -public record SchoolUpdateRequest( - @NotBlank(message = "domain은 공백일 수 없습니다.") String domain, - @NotBlank(message = "name은 공백일 수 없습니다.") String name -) { - -} diff --git a/backend/src/main/java/com/festago/school/dto/SchoolsResponse.java b/backend/src/main/java/com/festago/school/dto/SchoolsResponse.java deleted file mode 100644 index 129b1f887..000000000 --- a/backend/src/main/java/com/festago/school/dto/SchoolsResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.festago.school.dto; - -import static java.util.stream.Collectors.collectingAndThen; -import static java.util.stream.Collectors.toList; - -import com.festago.school.domain.School; -import java.util.List; - -public record SchoolsResponse( - List schools) { - - public static SchoolsResponse from(List schools) { - return schools.stream() - .map(SchoolResponse::from) - .collect(collectingAndThen(toList(), SchoolsResponse::new)); - } -} diff --git a/backend/src/main/java/com/festago/school/dto/command/SchoolCreateCommand.java b/backend/src/main/java/com/festago/school/dto/command/SchoolCreateCommand.java new file mode 100644 index 000000000..989ae928e --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/command/SchoolCreateCommand.java @@ -0,0 +1,26 @@ +package com.festago.school.dto.command; + +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import lombok.Builder; + +@Builder +public record SchoolCreateCommand( + String name, + String domain, + SchoolRegion region, + String logoUrl, + String backgroundImageUrl +) { + + public School toEntity() { + return new School( + null, + domain, + name, + logoUrl, + backgroundImageUrl, + region + ); + } +} diff --git a/backend/src/main/java/com/festago/school/dto/command/SchoolUpdateCommand.java b/backend/src/main/java/com/festago/school/dto/command/SchoolUpdateCommand.java new file mode 100644 index 000000000..58f6235d1 --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/command/SchoolUpdateCommand.java @@ -0,0 +1,14 @@ +package com.festago.school.dto.command; + +import com.festago.school.domain.SchoolRegion; +import lombok.Builder; + +@Builder +public record SchoolUpdateCommand( + String name, + String domain, + SchoolRegion region, + String logoUrl, + String backgroundImageUrl +) { +} diff --git a/backend/src/main/java/com/festago/school/dto/event/SchoolCreatedEvent.java b/backend/src/main/java/com/festago/school/dto/event/SchoolCreatedEvent.java new file mode 100644 index 000000000..20836f105 --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/event/SchoolCreatedEvent.java @@ -0,0 +1,9 @@ +package com.festago.school.dto.event; + +import com.festago.school.domain.School; + +public record SchoolCreatedEvent( + School school +) { + +} diff --git a/backend/src/main/java/com/festago/school/dto/event/SchoolDeletedEvent.java b/backend/src/main/java/com/festago/school/dto/event/SchoolDeletedEvent.java new file mode 100644 index 000000000..444713afc --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/event/SchoolDeletedEvent.java @@ -0,0 +1,7 @@ +package com.festago.school.dto.event; + +public record SchoolDeletedEvent( + Long schoolId +) { + +} diff --git a/backend/src/main/java/com/festago/school/dto/event/SchoolUpdatedEvent.java b/backend/src/main/java/com/festago/school/dto/event/SchoolUpdatedEvent.java new file mode 100644 index 000000000..923a87c81 --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/event/SchoolUpdatedEvent.java @@ -0,0 +1,9 @@ +package com.festago.school.dto.event; + +import com.festago.school.domain.School; + +public record SchoolUpdatedEvent( + School school +) { + +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java new file mode 100644 index 000000000..efdf8cd37 --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolDetailV1Response.java @@ -0,0 +1,17 @@ +package com.festago.school.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; +import java.util.List; + +public record SchoolDetailV1Response( + Long id, + String name, + String logoUrl, + String backgroundImageUrl, + List socialMedias +) { + + @QueryProjection + public SchoolDetailV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java new file mode 100644 index 000000000..a5d673131 --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolFestivalV1Response.java @@ -0,0 +1,24 @@ +package com.festago.school.dto.v1; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.festago.artist.infrastructure.JsonArtistsSerializer; +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +public record SchoolFestivalV1Response( + Long id, + String name, + LocalDate startDate, + LocalDate endDate, + String posterImageUrl, + @JsonRawValue + @ArraySchema(schema = @Schema(implementation = JsonArtistsSerializer.ArtistQueryModel.class)) + String artists +) { + + @QueryProjection + public SchoolFestivalV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolSearchV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolSearchV1Response.java new file mode 100644 index 000000000..be8d86636 --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolSearchV1Response.java @@ -0,0 +1,14 @@ +package com.festago.school.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; + +public record SchoolSearchV1Response( + Long id, + String name, + String logoUrl +) { + + @QueryProjection + public SchoolSearchV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolSocialMediaV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolSocialMediaV1Response.java new file mode 100644 index 000000000..087e3ca3a --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolSocialMediaV1Response.java @@ -0,0 +1,16 @@ +package com.festago.school.dto.v1; + +import com.festago.socialmedia.domain.SocialMediaType; +import com.querydsl.core.annotations.QueryProjection; + +public record SchoolSocialMediaV1Response( + SocialMediaType type, + String name, + String logoUrl, + String url +) { + + @QueryProjection + public SchoolSocialMediaV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolTotalSearchV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolTotalSearchV1Response.java new file mode 100644 index 000000000..34082bd1f --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolTotalSearchV1Response.java @@ -0,0 +1,13 @@ +package com.festago.school.dto.v1; + +import jakarta.annotation.Nullable; +import java.time.LocalDate; + +public record SchoolTotalSearchV1Response( + Long id, + String name, + String logoUrl, + @Nullable LocalDate upcomingFestivalStartDate +) { + +} diff --git a/backend/src/main/java/com/festago/school/dto/v1/SchoolUpcomingFestivalStartDateV1Response.java b/backend/src/main/java/com/festago/school/dto/v1/SchoolUpcomingFestivalStartDateV1Response.java new file mode 100644 index 000000000..d095dcb0f --- /dev/null +++ b/backend/src/main/java/com/festago/school/dto/v1/SchoolUpcomingFestivalStartDateV1Response.java @@ -0,0 +1,14 @@ +package com.festago.school.dto.v1; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; + +public record SchoolUpcomingFestivalStartDateV1Response( + Long schoolId, + LocalDate startDate +) { + + @QueryProjection + public SchoolUpcomingFestivalStartDateV1Response { + } +} diff --git a/backend/src/main/java/com/festago/school/presentation/v1/SchoolSearchV1Controller.java b/backend/src/main/java/com/festago/school/presentation/v1/SchoolSearchV1Controller.java new file mode 100644 index 000000000..5d645bf6f --- /dev/null +++ b/backend/src/main/java/com/festago/school/presentation/v1/SchoolSearchV1Controller.java @@ -0,0 +1,38 @@ +package com.festago.school.presentation.v1; + +import com.festago.common.util.Validator; +import com.festago.school.application.v1.SchoolTotalSearchV1QueryService; +import com.festago.school.dto.v1.SchoolTotalSearchV1Response; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/search/schools") +@Tag(name = "학교 검색 요청 V1") +@RequiredArgsConstructor +public class SchoolSearchV1Controller { + + private final SchoolTotalSearchV1QueryService schoolTotalSearchV1QueryService; + + @GetMapping + @Operation(description = "키워드로 학교를 검색한다.", summary = "학교 검색") + public ResponseEntity> searchSchools( + @RequestParam String keyword + ) { + validate(keyword); + return ResponseEntity.ok() + .body(schoolTotalSearchV1QueryService.searchSchools(keyword)); + } + + private void validate(String keyword) { + Validator.notBlank(keyword, "keyword"); + Validator.minLength(keyword, 2, "keyword"); + } +} diff --git a/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java b/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java new file mode 100644 index 000000000..c6cddfeb8 --- /dev/null +++ b/backend/src/main/java/com/festago/school/presentation/v1/SchoolV1Controller.java @@ -0,0 +1,58 @@ +package com.festago.school.presentation.v1; + +import com.festago.common.aop.ValidPageable; +import com.festago.common.dto.SliceResponse; +import com.festago.school.application.v1.SchoolV1QueryService; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.school.repository.v1.SchoolFestivalV1SearchCondition; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/schools") +@Tag(name = "학교 정보 요청 V1") +@RequiredArgsConstructor +public class SchoolV1Controller { + + private final SchoolV1QueryService schoolV1QueryService; + + @GetMapping("/{schoolId}") + @Operation(description = "학교의 정보를 조회한다.", summary = "학교 정보 조회") + public ResponseEntity findDetailById(@PathVariable Long schoolId) { + SchoolDetailV1Response response = schoolV1QueryService.findDetailById(schoolId); + return ResponseEntity.ok(response); + } + + @GetMapping("/{schoolId}/festivals") + @ValidPageable(maxSize = 20) + @Operation(description = "학교의 축제 목록을 조회한다.", summary = "학교 축제 목록 조회") + public ResponseEntity> findFestivalsBySchoolId( + @PathVariable Long schoolId, + @RequestParam(required = false) Long lastFestivalId, + @RequestParam(required = false) LocalDate lastStartDate, + @RequestParam(defaultValue = "false") Boolean isPast, + @Parameter(description = "0 < size <= 20") @RequestParam(defaultValue = "10") int size + ) { + LocalDate today = LocalDate.now(); + var searchCondition = new SchoolFestivalV1SearchCondition(lastFestivalId, lastStartDate, isPast, + PageRequest.ofSize(size)); + Slice response = schoolV1QueryService.findFestivalsBySchoolId( + schoolId, + today, + searchCondition + ); + return ResponseEntity.ok(SliceResponse.from(response)); + } +} diff --git a/backend/src/main/java/com/festago/school/repository/SchoolRepository.java b/backend/src/main/java/com/festago/school/repository/SchoolRepository.java index 43f1d8823..0895f8201 100644 --- a/backend/src/main/java/com/festago/school/repository/SchoolRepository.java +++ b/backend/src/main/java/com/festago/school/repository/SchoolRepository.java @@ -1,9 +1,29 @@ package com.festago.school.repository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; import com.festago.school.domain.School; -import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import org.springframework.data.repository.Repository; -public interface SchoolRepository extends JpaRepository { +public interface SchoolRepository extends Repository { - boolean existsByDomainOrName(String domain, String name); + School save(School school); + + Optional findById(Long id); + + void deleteById(Long id); + + boolean existsById(Long id); + + default School getOrThrow(Long schoolId) { + return findById(schoolId) + .orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)); + } + + boolean existsByDomain(String domain); + + boolean existsByName(String name); + + Optional findByName(String name); } diff --git a/backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalV1SearchCondition.java b/backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalV1SearchCondition.java new file mode 100644 index 000000000..59b00caf4 --- /dev/null +++ b/backend/src/main/java/com/festago/school/repository/v1/SchoolFestivalV1SearchCondition.java @@ -0,0 +1,13 @@ +package com.festago.school.repository.v1; + +import java.time.LocalDate; +import org.springframework.data.domain.Pageable; + +public record SchoolFestivalV1SearchCondition( + Long lastFestivalId, + LocalDate lastStartDate, + Boolean isPast, + Pageable pageable +) { + +} diff --git a/backend/src/main/java/com/festago/school/repository/v1/SchoolSearchV1QueryDslRepository.java b/backend/src/main/java/com/festago/school/repository/v1/SchoolSearchV1QueryDslRepository.java new file mode 100644 index 000000000..923a14846 --- /dev/null +++ b/backend/src/main/java/com/festago/school/repository/v1/SchoolSearchV1QueryDslRepository.java @@ -0,0 +1,34 @@ +package com.festago.school.repository.v1; + +import static com.festago.school.domain.QSchool.school; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.school.domain.School; +import com.festago.school.dto.v1.QSchoolSearchV1Response; +import com.festago.school.dto.v1.SchoolSearchV1Response; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class SchoolSearchV1QueryDslRepository extends QueryDslRepositorySupport { + + private static final long MAX_FETCH_SIZE = 50L; + + public SchoolSearchV1QueryDslRepository() { + super(School.class); + } + + public List searchSchools(String keyword) { + return select( + new QSchoolSearchV1Response( + school.id, + school.name, + school.logoUrl + )) + .from(school) + .where(school.name.contains(keyword)) + .orderBy(school.name.asc()) + .limit(MAX_FETCH_SIZE) + .fetch(); + } +} diff --git a/backend/src/main/java/com/festago/school/repository/v1/SchoolV1QueryDslRepository.java b/backend/src/main/java/com/festago/school/repository/v1/SchoolV1QueryDslRepository.java new file mode 100644 index 000000000..69340a21b --- /dev/null +++ b/backend/src/main/java/com/festago/school/repository/v1/SchoolV1QueryDslRepository.java @@ -0,0 +1,122 @@ +package com.festago.school.repository.v1; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; +import static com.festago.school.domain.QSchool.school; +import static com.festago.socialmedia.domain.QSocialMedia.socialMedia; +import static com.querydsl.core.group.GroupBy.groupBy; +import static com.querydsl.core.group.GroupBy.list; + +import com.festago.common.querydsl.QueryDslRepositorySupport; +import com.festago.school.domain.School; +import com.festago.school.dto.v1.QSchoolDetailV1Response; +import com.festago.school.dto.v1.QSchoolFestivalV1Response; +import com.festago.school.dto.v1.QSchoolSocialMediaV1Response; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.socialmedia.domain.OwnerType; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +@Repository +public class SchoolV1QueryDslRepository extends QueryDslRepositorySupport { + + public SchoolV1QueryDslRepository() { + super(School.class); + } + + public Optional findDetailById(Long schoolId) { + List response = selectFrom(school) + .where(school.id.eq(schoolId)) + .leftJoin(socialMedia).on(socialMedia.ownerId.eq(schoolId) + .and(socialMedia.ownerType.eq(OwnerType.SCHOOL))) + .transform( + groupBy(school.id).list( + new QSchoolDetailV1Response(school.id, school.name, school.logoUrl, school.backgroundUrl, + list( + new QSchoolSocialMediaV1Response( + socialMedia.mediaType, + socialMedia.name, + socialMedia.logoUrl, + socialMedia.url + ).skipNulls() + ) + ) + ) + ); + + if (response.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(response.get(0)); + } + + public Slice findFestivalsBySchoolId( + Long schoolId, + LocalDate today, + SchoolFestivalV1SearchCondition searchCondition + ) { + Pageable pageable = searchCondition.pageable(); + return applySlice( + pageable, + query -> query.select(new QSchoolFestivalV1Response(festival.id, + festival.name, + festival.festivalDuration.startDate, + festival.festivalDuration.endDate, + festival.posterImageUrl, + festivalQueryInfo.artistInfo + ) + ) + .from(festival) + .leftJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) + .where(festival.school.id.eq(schoolId), + addPhaseOption(searchCondition.isPast(), today), + addPagingOption( + searchCondition.lastFestivalId(), + searchCondition.lastStartDate(), + searchCondition.isPast() + )) + .orderBy(addOrderOption(searchCondition.isPast())) + ); + } + + private BooleanExpression addPhaseOption(boolean isPast, LocalDate today) { + if (isPast) { + return festival.festivalDuration.endDate.lt(today); + } + + return festival.festivalDuration.endDate.goe(today); + } + + private BooleanExpression addPagingOption(Long lastFestivalId, LocalDate lastStartDate, boolean isPast) { + if (isNotFirstPage(lastFestivalId, lastStartDate)) { + if (isPast) { + return festival.festivalDuration.startDate.lt(lastStartDate) + .or(festival.festivalDuration.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))); + } + return festival.festivalDuration.startDate.gt(lastStartDate) + .or(festival.festivalDuration.startDate.eq(lastStartDate) + .and(festival.id.gt(lastFestivalId))); + } + return null; + } + + private boolean isNotFirstPage(Long lastFestivalId, LocalDate lastStartDate) { + return lastFestivalId != null && lastStartDate != null; + } + + private OrderSpecifier addOrderOption(boolean isPast) { + if (isPast) { + return festival.festivalDuration.endDate.desc(); + } + return festival.festivalDuration.startDate.asc(); + } +} diff --git a/backend/src/main/java/com/festago/socialmedia/application/SocialMediaCommandService.java b/backend/src/main/java/com/festago/socialmedia/application/SocialMediaCommandService.java new file mode 100644 index 000000000..30f73a0b0 --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/application/SocialMediaCommandService.java @@ -0,0 +1,59 @@ +package com.festago.socialmedia.application; + +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.school.repository.SchoolRepository; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.dto.command.SocialMediaCreateCommand; +import com.festago.socialmedia.dto.command.SocialMediaUpdateCommand; +import com.festago.socialmedia.repository.SocialMediaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class SocialMediaCommandService { + + private final SocialMediaRepository socialMediaRepository; + private final SchoolRepository schoolRepository; + private final ArtistRepository artistRepository; + + public Long createSocialMedia(SocialMediaCreateCommand command) { + validateCreate(command); + SocialMedia socialMedia = socialMediaRepository.save(command.toEntity()); + return socialMedia.getId(); + } + + private void validateCreate(SocialMediaCreateCommand command) { + Long ownerId = command.ownerId(); + OwnerType ownerType = command.ownerType(); + SocialMediaType socialMediaType = command.socialMediaType(); + if (socialMediaRepository.existsByOwnerIdAndOwnerTypeAndMediaType(ownerId, ownerType, socialMediaType)) { + throw new BadRequestException(ErrorCode.DUPLICATE_SOCIAL_MEDIA); + } + // TODO 추상적인 에러 코드가 필요할지? ex) ErrorCode.SOCIAL_MEDIA_OWNER_NOT_FOUND + if (ownerType == OwnerType.ARTIST && !artistRepository.existsById(ownerId)) { + throw new NotFoundException(ErrorCode.ARTIST_NOT_FOUND); + } + if (ownerType == OwnerType.SCHOOL && !schoolRepository.existsById(ownerId)) { + throw new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND); + } + } + + public void updateSocialMedia(Long socialMediaId, SocialMediaUpdateCommand command) { + SocialMedia socialMedia = socialMediaRepository.getOrThrow(socialMediaId); + socialMedia.changeName(command.name()); + socialMedia.changeLogoUrl(command.logoUrl()); + socialMedia.changeUrl(command.url()); + } + + public void deleteSocialMedia(Long socialMediaId) { + socialMediaRepository.deleteById(socialMediaId); + } +} diff --git a/backend/src/main/java/com/festago/socialmedia/domain/OwnerType.java b/backend/src/main/java/com/festago/socialmedia/domain/OwnerType.java new file mode 100644 index 000000000..91b174984 --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/domain/OwnerType.java @@ -0,0 +1,7 @@ +package com.festago.socialmedia.domain; + +public enum OwnerType { + ARTIST, + SCHOOL, + ; +} diff --git a/backend/src/main/java/com/festago/socialmedia/domain/SocialMedia.java b/backend/src/main/java/com/festago/socialmedia/domain/SocialMedia.java new file mode 100644 index 000000000..2ae3bbdf7 --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/domain/SocialMedia.java @@ -0,0 +1,113 @@ +package com.festago.socialmedia.domain; + +import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.util.ImageUrlHelper; +import com.festago.common.util.Validator; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "social_media", + uniqueConstraints = { + @UniqueConstraint( + columnNames = {"owner_id", "owner_type", "media_type"} + ) + } +) +public class SocialMedia extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "owner_id") + private Long ownerId; + + @Enumerated(EnumType.STRING) + @Column(name = "owner_type", columnDefinition = "varchar") + private OwnerType ownerType; + + @Enumerated(EnumType.STRING) + @Column(name = "media_type", columnDefinition = "varchar") + private SocialMediaType mediaType; + + private String name; + + private String logoUrl; + + private String url; + + public SocialMedia(Long id, Long ownerId, OwnerType ownerType, SocialMediaType mediaType, String name, + String logoUrl, String url) { + Validator.notNull(ownerId, "ownerId"); + Validator.notNull(ownerType, "ownerType"); + Validator.notNull(mediaType, "mediaType"); + Validator.notBlank(name, "name"); + Validator.notBlank(url, "url"); + this.id = id; + this.ownerId = ownerId; + this.ownerType = ownerType; + this.mediaType = mediaType; + this.name = name; + this.logoUrl = ImageUrlHelper.getBlankStringIfBlank(logoUrl); + this.url = url; + } + + public SocialMedia(Long ownerId, OwnerType ownerType, SocialMediaType mediaType, String name, String logoUrl, + String url) { + this(null, ownerId, ownerType, mediaType, name, logoUrl, url); + } + + public void changeName(String name) { + Validator.notBlank(name, "name"); + this.name = name; + } + + public void changeUrl(String url) { + Validator.notBlank(url, "url"); + this.url = url; + } + + public void changeLogoUrl(String logoUrl) { + this.logoUrl = logoUrl; + } + + public Long getId() { + return id; + } + + public Long getOwnerId() { + return ownerId; + } + + public OwnerType getOwnerType() { + return ownerType; + } + + public SocialMediaType getMediaType() { + return mediaType; + } + + public String getName() { + return name; + } + + public String getLogoUrl() { + return logoUrl; + } + + public String getUrl() { + return url; + } +} diff --git a/backend/src/main/java/com/festago/socialmedia/domain/SocialMediaType.java b/backend/src/main/java/com/festago/socialmedia/domain/SocialMediaType.java new file mode 100644 index 000000000..dc15eb907 --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/domain/SocialMediaType.java @@ -0,0 +1,9 @@ +package com.festago.socialmedia.domain; + +public enum SocialMediaType { + YOUTUBE, + X, + INSTAGRAM, + FACEBOOK, + ; +} diff --git a/backend/src/main/java/com/festago/socialmedia/dto/command/SocialMediaCreateCommand.java b/backend/src/main/java/com/festago/socialmedia/dto/command/SocialMediaCreateCommand.java new file mode 100644 index 000000000..6a88c0e1c --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/dto/command/SocialMediaCreateCommand.java @@ -0,0 +1,28 @@ +package com.festago.socialmedia.dto.command; + +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; +import lombok.Builder; + +@Builder +public record SocialMediaCreateCommand( + Long ownerId, + OwnerType ownerType, + SocialMediaType socialMediaType, + String name, + String logoUrl, + String url +) { + + public SocialMedia toEntity() { + return new SocialMedia( + ownerId, + ownerType, + socialMediaType, + name, + logoUrl, + url + ); + } +} diff --git a/backend/src/main/java/com/festago/socialmedia/dto/command/SocialMediaUpdateCommand.java b/backend/src/main/java/com/festago/socialmedia/dto/command/SocialMediaUpdateCommand.java new file mode 100644 index 000000000..da5784e8a --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/dto/command/SocialMediaUpdateCommand.java @@ -0,0 +1,12 @@ +package com.festago.socialmedia.dto.command; + +import lombok.Builder; + +@Builder +public record SocialMediaUpdateCommand( + String name, + String url, + String logoUrl +) { + +} diff --git a/backend/src/main/java/com/festago/socialmedia/repository/SocialMediaRepository.java b/backend/src/main/java/com/festago/socialmedia/repository/SocialMediaRepository.java new file mode 100644 index 000000000..f1e6eec63 --- /dev/null +++ b/backend/src/main/java/com/festago/socialmedia/repository/SocialMediaRepository.java @@ -0,0 +1,24 @@ +package com.festago.socialmedia.repository; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface SocialMediaRepository extends Repository { + + default SocialMedia getOrThrow(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException(ErrorCode.SOCIAL_MEDIA_NOT_FOUND)); + } + + SocialMedia save(SocialMedia socialMedia); + + Optional findById(Long id); + + boolean existsByOwnerIdAndOwnerTypeAndMediaType(Long ownerId, OwnerType ownerType, SocialMediaType mediaType); + + void deleteById(Long socialMediaId); +} diff --git a/backend/src/main/java/com/festago/stage/application/RenewFestivalQueryInfoEventListener.java b/backend/src/main/java/com/festago/stage/application/RenewFestivalQueryInfoEventListener.java new file mode 100644 index 000000000..6282856da --- /dev/null +++ b/backend/src/main/java/com/festago/stage/application/RenewFestivalQueryInfoEventListener.java @@ -0,0 +1,40 @@ +package com.festago.stage.application; + +import com.festago.festival.application.FestivalQueryInfoArtistRenewService; +import com.festago.stage.domain.Stage; +import com.festago.stage.dto.event.StageCreatedEvent; +import com.festago.stage.dto.event.StageDeletedEvent; +import com.festago.stage.dto.event.StageUpdatedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class RenewFestivalQueryInfoEventListener { + + private final FestivalQueryInfoArtistRenewService festivalQueryInfoArtistRenewService; + + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void stageCreatedEventHandler(StageCreatedEvent event) { + Stage stage = event.stage(); + festivalQueryInfoArtistRenewService.renewArtistInfo(stage.getFestival().getId()); + } + + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void stageUpdatedEventHandler(StageUpdatedEvent event) { + Stage stage = event.stage(); + festivalQueryInfoArtistRenewService.renewArtistInfo(stage.getFestival().getId()); + } + + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void stageDeletedEventHandler(StageDeletedEvent event) { + Stage stage = event.stage(); + festivalQueryInfoArtistRenewService.renewArtistInfo(stage.getFestival().getId()); + } +} diff --git a/backend/src/main/java/com/festago/stage/application/StageQueryInfoEventListener.java b/backend/src/main/java/com/festago/stage/application/StageQueryInfoEventListener.java new file mode 100644 index 000000000..bd599b38a --- /dev/null +++ b/backend/src/main/java/com/festago/stage/application/StageQueryInfoEventListener.java @@ -0,0 +1,41 @@ +package com.festago.stage.application; + +import com.festago.stage.domain.Stage; +import com.festago.stage.dto.event.StageCreatedEvent; +import com.festago.stage.dto.event.StageDeletedEvent; +import com.festago.stage.dto.event.StageUpdatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StageQueryInfoEventListener { + + private final StageQueryInfoService stageQueryInfoService; + + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void stageCreatedEventHandler(StageCreatedEvent event) { + Stage stage = event.stage(); + stageQueryInfoService.initialStageQueryInfo(stage.getId()); + } + + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void stageUpdatedEventHandler(StageUpdatedEvent event) { + Stage stage = event.stage(); + stageQueryInfoService.renewalStageQueryInfo(stage.getId()); + } + + @EventListener + @Transactional(propagation = Propagation.MANDATORY) + public void stageDeletedEventHandler(StageDeletedEvent event) { + Stage stage = event.stage(); + stageQueryInfoService.deleteStageQueryInfo(stage.getId()); + } +} diff --git a/backend/src/main/java/com/festago/stage/application/StageQueryInfoService.java b/backend/src/main/java/com/festago/stage/application/StageQueryInfoService.java new file mode 100644 index 000000000..3bad1f730 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/application/StageQueryInfoService.java @@ -0,0 +1,56 @@ +package com.festago.stage.application; + +import com.festago.artist.domain.Artist; +import com.festago.artist.domain.ArtistsSerializer; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.NotFoundException; +import com.festago.stage.domain.StageQueryInfo; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageQueryInfoRepository; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class StageQueryInfoService { + + private final StageQueryInfoRepository stageQueryInfoRepository; + private final StageArtistRepository stageArtistRepository; + private final ArtistRepository artistRepository; + private final ArtistsSerializer serializer; + + public void initialStageQueryInfo(Long stageId) { + List artists = getStageArtists(stageId); + StageQueryInfo stageQueryInfo = StageQueryInfo.of(stageId, artists, serializer); + stageQueryInfoRepository.save(stageQueryInfo); + } + + private List getStageArtists(Long stageId) { + Set artistIds = stageArtistRepository.findAllArtistIdByStageId(stageId); + List artists = artistRepository.findByIdIn(artistIds); + if (artists.size() != artistIds.size()) { + log.error("StageArtist에 존재하지 않은 Artist가 있습니다. artistsIds=" + artistIds); + throw new InternalServerException(ErrorCode.ARTIST_NOT_FOUND); + } + return artists; + } + + public void renewalStageQueryInfo(Long stageId) { + StageQueryInfo stageQueryInfo = stageQueryInfoRepository.findByStageId(stageId) + .orElseThrow(() -> new NotFoundException(ErrorCode.STAGE_NOT_FOUND)); + List artists = getStageArtists(stageId); + stageQueryInfo.updateArtist(artists, serializer); + } + + public void deleteStageQueryInfo(Long stageId) { + stageQueryInfoRepository.deleteByStageId(stageId); + } +} diff --git a/backend/src/main/java/com/festago/stage/application/StageService.java b/backend/src/main/java/com/festago/stage/application/StageService.java deleted file mode 100644 index 75dfc63ab..000000000 --- a/backend/src/main/java/com/festago/stage/application/StageService.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.festago.stage.application; - -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.NotFoundException; -import com.festago.festival.domain.Festival; -import com.festago.festival.repository.FestivalRepository; -import com.festago.stage.domain.Stage; -import com.festago.stage.dto.StageCreateRequest; -import com.festago.stage.dto.StageResponse; -import com.festago.stage.dto.StageUpdateRequest; -import com.festago.stage.repository.StageRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional -@RequiredArgsConstructor -public class StageService { - - private final StageRepository stageRepository; - private final FestivalRepository festivalRepository; - - public StageResponse create(StageCreateRequest request) { - Festival festival = findFestival(request.festivalId()); - Stage newStage = stageRepository.save(new Stage( - request.startTime(), - request.lineUp(), - request.ticketOpenTime(), - festival)); - - return StageResponse.from(newStage); - } - - private Festival findFestival(Long festivalId) { - return festivalRepository.findById(festivalId) - .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); - } - - public StageResponse findDetail(Long stageId) { - Stage stage = findStage(stageId); - return StageResponse.from(stage); - } - - private Stage findStage(Long stageId) { - return stageRepository.findById(stageId) - .orElseThrow(() -> new NotFoundException(ErrorCode.STAGE_NOT_FOUND)); - } - - public void update(Long stageId, StageUpdateRequest request) { - Stage stage = findStage(stageId); - stage.changeTime(request.startTime(), request.ticketOpenTime()); - stage.changeLineUp(request.lineUp()); - } - - public void delete(Long stageId) { - try { - stageRepository.deleteById(stageId); - stageRepository.flush(); - } catch (DataIntegrityViolationException e) { - throw new BadRequestException(ErrorCode.DELETE_CONSTRAINT_STAGE); - } - } -} diff --git a/backend/src/main/java/com/festago/stage/application/command/StageCommandFacadeService.java b/backend/src/main/java/com/festago/stage/application/command/StageCommandFacadeService.java new file mode 100644 index 000000000..2916bd06a --- /dev/null +++ b/backend/src/main/java/com/festago/stage/application/command/StageCommandFacadeService.java @@ -0,0 +1,27 @@ +package com.festago.stage.application.command; + +import com.festago.stage.dto.command.StageCreateCommand; +import com.festago.stage.dto.command.StageUpdateCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StageCommandFacadeService { + + private final StageCreateService stageCreateService; + private final StageUpdateService stageUpdateService; + private final StageDeleteService stageDeleteService; + + public Long createStage(StageCreateCommand command) { + return stageCreateService.createStage(command); + } + + public void updateStage(Long stageId, StageUpdateCommand command) { + stageUpdateService.updateStage(stageId, command); + } + + public void deleteStage(Long stageId) { + stageDeleteService.deleteStage(stageId); + } +} diff --git a/backend/src/main/java/com/festago/stage/application/command/StageCreateService.java b/backend/src/main/java/com/festago/stage/application/command/StageCreateService.java new file mode 100644 index 000000000..b70075cca --- /dev/null +++ b/backend/src/main/java/com/festago/stage/application/command/StageCreateService.java @@ -0,0 +1,61 @@ +package com.festago.stage.application.command; + +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.util.Validator; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.StageArtist; +import com.festago.stage.dto.command.StageCreateCommand; +import com.festago.stage.dto.event.StageCreatedEvent; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class StageCreateService { + + private static final int MAX_ARTIST_SIZE = 10; + + private final StageRepository stageRepository; + private final FestivalRepository festivalRepository; + private final ArtistRepository artistRepository; + private final StageArtistRepository stageArtistRepository; + private final ApplicationEventPublisher eventPublisher; + + public Long createStage(StageCreateCommand command) { + validate(command); + Festival festival = festivalRepository.getOrThrow(command.festivalId()); + Stage stage = stageRepository.save(new Stage( + command.startTime(), + command.ticketOpenTime(), + festival + )); + List artistIds = command.artistIds(); + createStageArtist(artistIds, stage); + eventPublisher.publishEvent(new StageCreatedEvent(stage)); + return stage.getId(); + } + + private void validate(StageCreateCommand command) { + List artistIds = command.artistIds(); + Validator.maxSize(artistIds, MAX_ARTIST_SIZE, "artistIds"); + Validator.notDuplicate(artistIds, "artistIds"); + } + + private void createStageArtist(List artistIds, Stage stage) { + if (artistRepository.countByIdIn(artistIds) == artistIds.size()) { + artistIds.forEach(artistId -> stageArtistRepository.save(new StageArtist(stage.getId(), artistId))); + } else { + throw new NotFoundException(ErrorCode.ARTIST_NOT_FOUND); + } + } +} diff --git a/backend/src/main/java/com/festago/stage/application/command/StageDeleteService.java b/backend/src/main/java/com/festago/stage/application/command/StageDeleteService.java new file mode 100644 index 000000000..73149505c --- /dev/null +++ b/backend/src/main/java/com/festago/stage/application/command/StageDeleteService.java @@ -0,0 +1,27 @@ +package com.festago.stage.application.command; + +import com.festago.stage.dto.event.StageDeletedEvent; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class StageDeleteService { + + private final StageRepository stageRepository; + private final StageArtistRepository stageArtistRepository; + private final ApplicationEventPublisher eventPublisher; + + public void deleteStage(Long stageId) { + stageRepository.findByIdWithFetch(stageId).ifPresent(stage -> { + stageArtistRepository.deleteByStageId(stageId); + stageRepository.deleteById(stageId); + eventPublisher.publishEvent(new StageDeletedEvent(stage)); + }); + } +} diff --git a/backend/src/main/java/com/festago/stage/application/command/StageUpdateService.java b/backend/src/main/java/com/festago/stage/application/command/StageUpdateService.java new file mode 100644 index 000000000..9b292c00b --- /dev/null +++ b/backend/src/main/java/com/festago/stage/application/command/StageUpdateService.java @@ -0,0 +1,57 @@ +package com.festago.stage.application.command; + +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.util.Validator; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.StageArtist; +import com.festago.stage.dto.command.StageUpdateCommand; +import com.festago.stage.dto.event.StageUpdatedEvent; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class StageUpdateService { + + private static final int MAX_ARTIST_SIZE = 10; + + private final StageRepository stageRepository; + private final ArtistRepository artistRepository; + private final StageArtistRepository stageArtistRepository; + private final ApplicationEventPublisher eventPublisher; + + public void updateStage(Long stageId, StageUpdateCommand command) { + validate(command); + LocalDateTime startTime = command.startTime(); + LocalDateTime ticketOpenTime = command.ticketOpenTime(); + List artistIds = command.artistIds(); + Stage stage = stageRepository.findByIdWithFetch(stageId) + .orElseThrow(() -> new NotFoundException(ErrorCode.STAGE_NOT_FOUND)); + stage.changeTime(startTime, ticketOpenTime); + renewStageArtist(stage, artistIds); + eventPublisher.publishEvent(new StageUpdatedEvent(stage)); + } + + private void validate(StageUpdateCommand command) { + List artistIds = command.artistIds(); + Validator.maxSize(artistIds, MAX_ARTIST_SIZE, "artistIds"); + Validator.notDuplicate(artistIds, "artistIds"); + } + + private void renewStageArtist(Stage stage, List artistIds) { + if (artistRepository.countByIdIn(artistIds) != artistIds.size()) { + throw new NotFoundException(ErrorCode.ARTIST_NOT_FOUND); + } + stageArtistRepository.deleteByStageId(stage.getId()); + artistIds.forEach(artistId -> stageArtistRepository.save(new StageArtist(stage.getId(), artistId))); + } +} diff --git a/backend/src/main/java/com/festago/stage/domain/FestivalIdStageArtistsResolverImpl.java b/backend/src/main/java/com/festago/stage/domain/FestivalIdStageArtistsResolverImpl.java new file mode 100644 index 000000000..7a9af2170 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/domain/FestivalIdStageArtistsResolverImpl.java @@ -0,0 +1,38 @@ +package com.festago.stage.domain; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.FestivalIdStageArtistsResolver; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Transactional(propagation = Propagation.MANDATORY) +public class FestivalIdStageArtistsResolverImpl implements FestivalIdStageArtistsResolver { + + private final StageRepository stageRepository; + private final StageArtistRepository stageArtistRepository; + private final ArtistRepository artistRepository; + + @Override + public List resolve(Long festivalId) { + List stageIds = stageRepository.findAllByFestivalId(festivalId).stream() + .map(Stage::getId) + .toList(); + Set artistIds = stageArtistRepository.findAllArtistIdByStageIdIn(stageIds); + List artists = artistRepository.findByIdIn(artistIds); + if (artists.size() != artistIds.size()) { + throw new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND); + } + return artists; + } +} diff --git a/backend/src/main/java/com/festago/stage/domain/Stage.java b/backend/src/main/java/com/festago/stage/domain/Stage.java index 5c5cea7d7..9578e1270 100644 --- a/backend/src/main/java/com/festago/stage/domain/Stage.java +++ b/backend/src/main/java/com/festago/stage/domain/Stage.java @@ -14,13 +14,11 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; import lombok.NoArgsConstructor; -import org.springframework.util.Assert; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -33,9 +31,6 @@ public class Stage extends BaseTimeEntity { @NotNull private LocalDateTime startTime; - @Size(max = 255) - private String lineUp; - @NotNull private LocalDateTime ticketOpenTime; @@ -46,43 +41,36 @@ public class Stage extends BaseTimeEntity { @OneToMany(mappedBy = "stage", fetch = FetchType.LAZY) private List tickets = new ArrayList<>(); - public Stage(LocalDateTime startTime, String lineUp, LocalDateTime ticketOpenTime, Festival festival) { - this(null, startTime, lineUp, ticketOpenTime, festival); - } + @OneToMany(fetch = FetchType.LAZY, mappedBy = "stageId") + private List artists = new ArrayList<>(); public Stage(LocalDateTime startTime, LocalDateTime ticketOpenTime, Festival festival) { - this(null, startTime, null, ticketOpenTime, festival); + this(null, startTime, ticketOpenTime, festival); } - public Stage(Long id, LocalDateTime startTime, String lineUp, LocalDateTime ticketOpenTime, + public Stage(Long id, LocalDateTime startTime, LocalDateTime ticketOpenTime, Festival festival) { - validate(startTime, lineUp, ticketOpenTime, festival); + validate(startTime, ticketOpenTime, festival); this.id = id; this.startTime = startTime; - this.lineUp = lineUp; this.ticketOpenTime = ticketOpenTime; this.festival = festival; } - private void validate(LocalDateTime startTime, String lineUp, LocalDateTime ticketOpenTime, Festival festival) { - validateLineUp(lineUp); + private void validate(LocalDateTime startTime, LocalDateTime ticketOpenTime, Festival festival) { validateFestival(festival); validateTime(startTime, ticketOpenTime, festival); } - private void validateLineUp(String lineUp) { - Validator.maxLength(lineUp, 255, "lineUp은 50글자를 넘을 수 없습니다."); - } - private void validateFestival(Festival festival) { - Assert.notNull(festival, "festival은 null 값이 될 수 없습니다."); + Validator.notNull(festival, "festival"); } private void validateTime(LocalDateTime startTime, LocalDateTime ticketOpenTime, Festival festival) { - Assert.notNull(startTime, "startTime은 null 값이 될 수 없습니다."); - Assert.notNull(ticketOpenTime, "ticketOpenTime은 null 값이 될 수 없습니다."); + Validator.notNull(startTime, "startTime"); + Validator.notNull(ticketOpenTime, "ticketOpenTime"); if (ticketOpenTime.isAfter(startTime) || ticketOpenTime.isEqual(startTime)) { - throw new IllegalArgumentException("티켓 오픈 시간은 공연 시작 이전 이어야 합니다."); + throw new BadRequestException(ErrorCode.INVALID_TICKET_OPEN_TIME); } if (festival.isNotInDuration(startTime)) { throw new BadRequestException(ErrorCode.INVALID_STAGE_START_TIME); @@ -99,11 +87,6 @@ public void changeTime(LocalDateTime startTime, LocalDateTime ticketOpenTime) { this.ticketOpenTime = ticketOpenTime; } - public void changeLineUp(String lineUp) { - validateLineUp(lineUp); - this.lineUp = lineUp; - } - public Long getId() { return id; } @@ -112,10 +95,6 @@ public LocalDateTime getStartTime() { return startTime; } - public String getLineUp() { - return lineUp; - } - public LocalDateTime getTicketOpenTime() { return ticketOpenTime; } diff --git a/backend/src/main/java/com/festago/stage/domain/StageArtist.java b/backend/src/main/java/com/festago/stage/domain/StageArtist.java new file mode 100644 index 000000000..f4dcf2cd4 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/domain/StageArtist.java @@ -0,0 +1,48 @@ +package com.festago.stage.domain; + +import com.festago.common.util.Validator; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StageArtist { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private Long stageId; + private Long artistId; + + public StageArtist(Long stageId, Long artistId) { + this(null, stageId, artistId); + } + + public StageArtist(Long id, Long stageId, Long artistId) { + validate(stageId, artistId); + this.id = id; + this.stageId = stageId; + this.artistId = artistId; + } + + private void validate(Long stageId, Long artistId) { + Validator.notNull(stageId, "stageId"); + Validator.notNull(artistId, "artistId"); + } + + public Long getId() { + return id; + } + + public Long getStageId() { + return stageId; + } + + public Long getArtistId() { + return artistId; + } +} diff --git a/backend/src/main/java/com/festago/stage/domain/StageQueryInfo.java b/backend/src/main/java/com/festago/stage/domain/StageQueryInfo.java new file mode 100644 index 000000000..b3006979e --- /dev/null +++ b/backend/src/main/java/com/festago/stage/domain/StageQueryInfo.java @@ -0,0 +1,57 @@ +package com.festago.stage.domain; + +import com.festago.artist.domain.Artist; +import com.festago.artist.domain.ArtistsSerializer; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StageQueryInfo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long stageId; + + /** + * 역정규화를 위한 아티스트의 배열 JSON 컬럼 + */ + @NotNull + @Column(columnDefinition = "TEXT") + private String artistInfo; + + private StageQueryInfo(Long stageId, String artistInfo) { + this.stageId = stageId; + this.artistInfo = artistInfo; + } + + public static StageQueryInfo of(Long stageId, List artists, ArtistsSerializer serializer) { + return new StageQueryInfo(stageId, serializer.serialize(artists)); + } + + public void updateArtist(List artists, ArtistsSerializer serializer) { + this.artistInfo = serializer.serialize(artists); + } + + public Long getId() { + return id; + } + + public Long getStageId() { + return stageId; + } + + public String getArtistInfo() { + return artistInfo; + } +} diff --git a/backend/src/main/java/com/festago/stage/domain/validator/festival/ExistsStageFestivalDeleteValidator.java b/backend/src/main/java/com/festago/stage/domain/validator/festival/ExistsStageFestivalDeleteValidator.java new file mode 100644 index 000000000..72fd18ec9 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/domain/validator/festival/ExistsStageFestivalDeleteValidator.java @@ -0,0 +1,24 @@ +package com.festago.stage.domain.validator.festival; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.validator.FestivalDeleteValidator; +import com.festago.stage.repository.StageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ExistsStageFestivalDeleteValidator implements FestivalDeleteValidator { + + private final StageRepository stageRepository; + + @Override + public void validate(Long festivalId) { + if (stageRepository.existsByFestivalId(festivalId)) { + throw new BadRequestException(ErrorCode.FESTIVAL_DELETE_CONSTRAINT_EXISTS_STAGE); + } + } +} diff --git a/backend/src/main/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidator.java b/backend/src/main/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidator.java new file mode 100644 index 000000000..e799a0f84 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidator.java @@ -0,0 +1,33 @@ +package com.festago.stage.domain.validator.festival; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.validator.FestivalUpdateValidator; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 축제를 수정할 때, 축제에 포함된 공연이 수정할 축제 기간의 범위를 벗어난 공연이 있는지 검증하는 클래스 + */ +@Component +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class OutOfDateStageFestivalUpdateValidator implements FestivalUpdateValidator { + + private final StageRepository stageRepository; + + @Override + public void validate(Festival festival) { + List stages = stageRepository.findAllByFestivalId(festival.getId()); + boolean isOutOfDate = stages.stream() + .anyMatch(stage -> festival.isNotInDuration(stage.getStartTime())); + if (isOutOfDate) { + throw new BadRequestException(ErrorCode.FESTIVAL_UPDATE_OUT_OF_DATE_STAGE_START_TIME); + } + } +} diff --git a/backend/src/main/java/com/festago/stage/dto/StageCreateRequest.java b/backend/src/main/java/com/festago/stage/dto/StageCreateRequest.java deleted file mode 100644 index 850a43242..000000000 --- a/backend/src/main/java/com/festago/stage/dto/StageCreateRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.festago.stage.dto; - -import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.format.annotation.DateTimeFormat.ISO; - -public record StageCreateRequest( - @NotNull(message = "startTime은 null일 수 없습니다.") - @DateTimeFormat(iso = ISO.DATE_TIME) - LocalDateTime startTime, - String lineUp, - @NotNull(message = "ticketOpenTime은 null일 수 없습니다.") - @DateTimeFormat(iso = ISO.DATE_TIME) - LocalDateTime ticketOpenTime, - @NotNull(message = "festivalId는 null 일 수 없습니다.") - Long festivalId -) { - -} diff --git a/backend/src/main/java/com/festago/stage/dto/StageResponse.java b/backend/src/main/java/com/festago/stage/dto/StageResponse.java deleted file mode 100644 index 4355a0a11..000000000 --- a/backend/src/main/java/com/festago/stage/dto/StageResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.festago.stage.dto; - -import com.festago.stage.domain.Stage; -import java.time.LocalDateTime; - -public record StageResponse( - Long id, - Long festivalId, - LocalDateTime startTime, - LocalDateTime ticketOpenTime, - String lineUp) { - - public static StageResponse from(Stage stage) { - return new StageResponse( - stage.getId(), - stage.getFestival().getId(), - stage.getStartTime(), - stage.getTicketOpenTime(), - stage.getLineUp() - ); - } -} diff --git a/backend/src/main/java/com/festago/stage/dto/StageUpdateRequest.java b/backend/src/main/java/com/festago/stage/dto/StageUpdateRequest.java deleted file mode 100644 index 3a5241eb0..000000000 --- a/backend/src/main/java/com/festago/stage/dto/StageUpdateRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.festago.stage.dto; - -import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.format.annotation.DateTimeFormat.ISO; - -public record StageUpdateRequest( - @NotNull(message = "startTime는 null일 수 없습니다.") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime startTime, - @NotNull(message = "ticketOpenTime는 null일 수 없습니다.") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime ticketOpenTime, - String lineUp -) { - -} diff --git a/backend/src/main/java/com/festago/stage/dto/command/StageCreateCommand.java b/backend/src/main/java/com/festago/stage/dto/command/StageCreateCommand.java new file mode 100644 index 000000000..f696c102c --- /dev/null +++ b/backend/src/main/java/com/festago/stage/dto/command/StageCreateCommand.java @@ -0,0 +1,15 @@ +package com.festago.stage.dto.command; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record StageCreateCommand( + Long festivalId, + LocalDateTime startTime, + LocalDateTime ticketOpenTime, + List artistIds +) { + +} diff --git a/backend/src/main/java/com/festago/stage/dto/command/StageUpdateCommand.java b/backend/src/main/java/com/festago/stage/dto/command/StageUpdateCommand.java new file mode 100644 index 000000000..86b064d0b --- /dev/null +++ b/backend/src/main/java/com/festago/stage/dto/command/StageUpdateCommand.java @@ -0,0 +1,14 @@ +package com.festago.stage.dto.command; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record StageUpdateCommand( + LocalDateTime startTime, + LocalDateTime ticketOpenTime, + List artistIds +) { + +} diff --git a/backend/src/main/java/com/festago/stage/dto/event/StageCreatedEvent.java b/backend/src/main/java/com/festago/stage/dto/event/StageCreatedEvent.java new file mode 100644 index 000000000..cce3aae7c --- /dev/null +++ b/backend/src/main/java/com/festago/stage/dto/event/StageCreatedEvent.java @@ -0,0 +1,9 @@ +package com.festago.stage.dto.event; + +import com.festago.stage.domain.Stage; + +public record StageCreatedEvent( + Stage stage +) { + +} diff --git a/backend/src/main/java/com/festago/stage/dto/event/StageDeletedEvent.java b/backend/src/main/java/com/festago/stage/dto/event/StageDeletedEvent.java new file mode 100644 index 000000000..d52d46de6 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/dto/event/StageDeletedEvent.java @@ -0,0 +1,9 @@ +package com.festago.stage.dto.event; + +import com.festago.stage.domain.Stage; + +public record StageDeletedEvent( + Stage stage +) { + +} diff --git a/backend/src/main/java/com/festago/stage/dto/event/StageUpdatedEvent.java b/backend/src/main/java/com/festago/stage/dto/event/StageUpdatedEvent.java new file mode 100644 index 000000000..0344de20b --- /dev/null +++ b/backend/src/main/java/com/festago/stage/dto/event/StageUpdatedEvent.java @@ -0,0 +1,9 @@ +package com.festago.stage.dto.event; + +import com.festago.stage.domain.Stage; + +public record StageUpdatedEvent( + Stage stage +) { + +} diff --git a/backend/src/main/java/com/festago/stage/repository/StageArtistRepository.java b/backend/src/main/java/com/festago/stage/repository/StageArtistRepository.java new file mode 100644 index 000000000..0c239b68a --- /dev/null +++ b/backend/src/main/java/com/festago/stage/repository/StageArtistRepository.java @@ -0,0 +1,21 @@ +package com.festago.stage.repository; + +import com.festago.stage.domain.StageArtist; +import java.util.List; +import java.util.Set; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +public interface StageArtistRepository extends Repository { + + StageArtist save(StageArtist stageArtist); + + @Query("select sa.artistId from StageArtist sa where sa.stageId = :stageId") + Set findAllArtistIdByStageId(@Param("stageId") Long stageId); + + @Query("select sa.artistId from StageArtist sa where sa.stageId in :stageIds") + Set findAllArtistIdByStageIdIn(@Param("stageIds") List stageIds); + + void deleteByStageId(Long stageId); +} diff --git a/backend/src/main/java/com/festago/stage/repository/StageQueryInfoRepository.java b/backend/src/main/java/com/festago/stage/repository/StageQueryInfoRepository.java new file mode 100644 index 000000000..60e21bcc8 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/repository/StageQueryInfoRepository.java @@ -0,0 +1,14 @@ +package com.festago.stage.repository; + +import com.festago.stage.domain.StageQueryInfo; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface StageQueryInfoRepository extends Repository { + + StageQueryInfo save(StageQueryInfo stageQueryInfo); + + Optional findByStageId(Long stageId); + + void deleteByStageId(Long stageId); +} diff --git a/backend/src/main/java/com/festago/stage/repository/StageRepository.java b/backend/src/main/java/com/festago/stage/repository/StageRepository.java index b7203b92b..584107e74 100644 --- a/backend/src/main/java/com/festago/stage/repository/StageRepository.java +++ b/backend/src/main/java/com/festago/stage/repository/StageRepository.java @@ -3,25 +3,20 @@ import com.festago.stage.domain.Stage; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface StageRepository extends JpaRepository { - - @Query(""" - SELECT s FROM Stage s - LEFT JOIN FETCH s.tickets t - LEFT JOIN FETCH t.ticketAmount - WHERE s.festival.id = :festivalId - """) - List findAllDetailByFestivalId(@Param("festivalId") Long festivalId); - - @Query(""" - SELECT s FROM Stage s - LEFT JOIN s.festival f - LEFT JOIN f.school sc - WHERE s.id = :id - """) - Optional findByIdWithFetch(@Param("id") Long id); +import org.springframework.data.repository.Repository; + +public interface StageRepository extends Repository, StageRepositoryCustom { + + Stage save(Stage stage); + + Optional findById(Long stageId); + + void deleteById(Long stageId); + + void flush(); + + boolean existsByFestivalId(Long festivalId); + + List findAllByFestivalId(Long festivalId); + } diff --git a/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustom.java b/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustom.java new file mode 100644 index 000000000..d896b33fe --- /dev/null +++ b/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustom.java @@ -0,0 +1,12 @@ +package com.festago.stage.repository; + +import com.festago.stage.domain.Stage; +import java.util.List; +import java.util.Optional; + +public interface StageRepositoryCustom { + + List findAllDetailByFestivalId(Long festivalId); + + Optional findByIdWithFetch(Long id); +} diff --git a/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustomImpl.java b/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustomImpl.java new file mode 100644 index 000000000..749a34269 --- /dev/null +++ b/backend/src/main/java/com/festago/stage/repository/StageRepositoryCustomImpl.java @@ -0,0 +1,37 @@ +package com.festago.stage.repository; + +import static com.festago.festival.domain.QFestival.festival; +import static com.festago.school.domain.QSchool.school; +import static com.festago.stage.domain.QStage.stage; +import static com.festago.ticket.domain.QTicket.ticket; +import static com.festago.ticket.domain.QTicketAmount.ticketAmount; + +import com.festago.stage.domain.Stage; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class StageRepositoryCustomImpl implements StageRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllDetailByFestivalId(Long festivalId) { + return queryFactory.selectFrom(stage) + .leftJoin(stage.tickets, ticket).fetchJoin() + .leftJoin(ticket.ticketAmount, ticketAmount).fetchJoin() + .where(stage.festival.id.eq(festivalId)) + .fetch(); + } + + @Override + public Optional findByIdWithFetch(Long id) { + return Optional.ofNullable(queryFactory.selectFrom(stage) + .leftJoin(stage.festival, festival).fetchJoin() + .leftJoin(festival.school, school).fetchJoin() + .where(stage.id.eq(id)) + .fetchOne()); + } +} diff --git a/backend/src/main/java/com/festago/student/application/MailClient.java b/backend/src/main/java/com/festago/student/application/MailClient.java index 29f0ec11c..f4900c230 100644 --- a/backend/src/main/java/com/festago/student/application/MailClient.java +++ b/backend/src/main/java/com/festago/student/application/MailClient.java @@ -1,8 +1,9 @@ package com.festago.student.application; -import com.festago.student.domain.VerificationMailPayload; +import java.util.function.Consumer; +import org.springframework.mail.MailMessage; public interface MailClient { - void send(VerificationMailPayload payload); + void send(Consumer mailMessageConsumer); } diff --git a/backend/src/main/java/com/festago/student/application/StudentService.java b/backend/src/main/java/com/festago/student/application/StudentService.java index 82c1ba037..dc7ec3cdc 100644 --- a/backend/src/main/java/com/festago/student/application/StudentService.java +++ b/backend/src/main/java/com/festago/student/application/StudentService.java @@ -3,7 +3,6 @@ import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; import com.festago.common.exception.NotFoundException; -import com.festago.common.exception.TooManyRequestException; import com.festago.member.domain.Member; import com.festago.member.repository.MemberRepository; import com.festago.school.domain.School; @@ -11,7 +10,7 @@ import com.festago.student.domain.Student; import com.festago.student.domain.StudentCode; import com.festago.student.domain.VerificationCode; -import com.festago.student.domain.VerificationMailPayload; +import com.festago.student.dto.StudentResponse; import com.festago.student.dto.StudentSendMailRequest; import com.festago.student.dto.StudentVerificateRequest; import com.festago.student.repository.StudentCodeRepository; @@ -36,12 +35,16 @@ public class StudentService { private final Clock clock; public void sendVerificationMail(Long memberId, StudentSendMailRequest request) { + StudentCode studentCode = createStudentCode(memberId, request); + validate(studentCode); + saveStudentCodeOrReissue(studentCode); + sendEmail(studentCode); + } + + private StudentCode createStudentCode(Long memberId, StudentSendMailRequest request) { Member member = findMember(memberId); School school = findSchool(request.schoolId()); - validate(memberId, request); - VerificationCode code = codeProvider.provide(); - saveStudentCode(code, member, school, request.username()); - mailClient.send(new VerificationMailPayload(code, request.username(), school.getDomain())); + return new StudentCode(codeProvider.provide(), school, member, request.username(), LocalDateTime.now(clock)); } private Member findMember(Long memberId) { @@ -54,19 +57,9 @@ private School findSchool(Long schoolId) { .orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)); } - private void validate(Long memberId, StudentSendMailRequest request) { - validateFrequentRequest(memberId); - validateStudent(memberId); - validateDuplicateEmail(request); - } - - private void validateFrequentRequest(Long memberId) { - studentCodeRepository.findByMemberId(memberId) - .ifPresent(code -> { - if (!code.canReissue(LocalDateTime.now(clock))) { - throw new TooManyRequestException(ErrorCode.TOO_FREQUENT_REQUESTS); - } - }); + private void validate(StudentCode studentCode) { + validateStudent(studentCode.getMember().getId()); + validateDuplicateEmail(studentCode.getUsername(), studentCode.getSchool().getId()); } private void validateStudent(Long memberId) { @@ -75,27 +68,47 @@ private void validateStudent(Long memberId) { } } - private void validateDuplicateEmail(StudentSendMailRequest request) { - if (studentRepository.existsByUsernameAndSchoolId(request.username(), request.schoolId())) { + private void validateDuplicateEmail(String username, Long schoolId) { + if (studentRepository.existsByUsernameAndSchoolId(username, schoolId)) { throw new BadRequestException(ErrorCode.DUPLICATE_STUDENT_EMAIL); } } - private void saveStudentCode(VerificationCode code, Member member, School school, String username) { - studentCodeRepository.findByMemberId(member.getId()) - .ifPresentOrElse( - studentCode -> studentCode.reissue(code, school, username), - () -> studentCodeRepository.save(new StudentCode(code, school, member, username)) - ); + private void saveStudentCodeOrReissue(StudentCode studentCode) { + studentCodeRepository.findByMemberId(studentCode.getMember().getId()) + .ifPresentOrElse(existsCode -> { + existsCode.reissue(studentCode); + }, () -> { + studentCodeRepository.save(studentCode); + }); + } + + private void sendEmail(StudentCode studentCode) { + mailClient.send(mail -> { + mail.setTo(studentCode.getEmail()); + mail.setSubject("[페스타고] 학생 이메일 인증 코드"); + mail.setText(""" + 페스타고 학생 이메일 인증 코드입니다. + Code는 다음과 같습니다. + %s + """.formatted(studentCode.getCode())); + }); } - public void verificate(Long memberId, StudentVerificateRequest request) { + public void verify(Long memberId, StudentVerificateRequest request) { validateStudent(memberId); Member member = findMember(memberId); - StudentCode studentCode = studentCodeRepository.findByCodeAndMember(new VerificationCode(request.code()), - member) + VerificationCode verificationCode = new VerificationCode(request.code()); + StudentCode studentCode = studentCodeRepository.findByCodeAndMember(verificationCode, member) .orElseThrow(() -> new BadRequestException(ErrorCode.INVALID_STUDENT_VERIFICATION_CODE)); studentRepository.save(new Student(member, studentCode.getSchool(), studentCode.getUsername())); studentCodeRepository.deleteByMember(member); } + + @Transactional(readOnly = true) + public StudentResponse findVerification(Long memberId) { + return studentRepository.findByMemberIdWithFetch(memberId) + .map(StudentResponse::verified) + .orElseGet(StudentResponse::notVerified); + } } diff --git a/backend/src/main/java/com/festago/student/domain/Student.java b/backend/src/main/java/com/festago/student/domain/Student.java index b1559d660..c93d80ea8 100644 --- a/backend/src/main/java/com/festago/student/domain/Student.java +++ b/backend/src/main/java/com/festago/student/domain/Student.java @@ -1,8 +1,7 @@ package com.festago.student.domain; import com.festago.common.domain.BaseTimeEntity; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; +import com.festago.common.util.Validator; import com.festago.member.domain.Member; import com.festago.school.domain.School; import jakarta.persistence.Entity; @@ -21,6 +20,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Student extends BaseTimeEntity { + private static final int MAX_USERNAME_LENGTH = 255; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -33,7 +34,7 @@ public class Student extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) private School school; - @Size(max = 255) + @Size(max = MAX_USERNAME_LENGTH) @NotNull private String username; @@ -50,29 +51,23 @@ public Student(Long id, Member member, School school, String username) { } private void validate(Member member, School school, String username) { - checkNotNull(member, school, username); - checkLength(username); + validateMember(member); + validateSchool(school); + validateUsername(username); } - private void checkNotNull(Member member, School school, String username) { - if (member == null || - school == null || - username == null) { - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); - } + private void validateMember(Member member) { + Validator.notNull(member, "member"); } - private void checkLength(String username) { - if (overLength(username, 255)) { - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); - } + private void validateSchool(School school) { + Validator.notNull(school, "school"); } - private boolean overLength(String target, int maxLength) { - if (target == null) { - return false; - } - return target.length() > maxLength; + private void validateUsername(String username) { + String fieldName = "username"; + Validator.notBlank(username, fieldName); + Validator.maxLength(username, MAX_USERNAME_LENGTH, fieldName); } public Long getId() { diff --git a/backend/src/main/java/com/festago/student/domain/StudentCode.java b/backend/src/main/java/com/festago/student/domain/StudentCode.java index 899993530..6d3615f27 100644 --- a/backend/src/main/java/com/festago/student/domain/StudentCode.java +++ b/backend/src/main/java/com/festago/student/domain/StudentCode.java @@ -3,7 +3,9 @@ import static java.time.temporal.ChronoUnit.SECONDS; import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.TooManyRequestException; +import com.festago.common.exception.UnexpectedException; +import com.festago.common.util.Validator; import com.festago.member.domain.Member; import com.festago.school.domain.School; import jakarta.persistence.Embedded; @@ -17,12 +19,11 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToOne; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import org.springframework.util.StringUtils; @Entity @EntityListeners(AuditingEntityListener.class) @@ -30,6 +31,7 @@ public class StudentCode { private static final int MIN_REQUEST_TERM_SECONDS = 30; + private static final int MAX_USERNAME_LENGTH = 255; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -45,19 +47,20 @@ public class StudentCode { @JoinColumn(unique = true) private Member member; + @NotNull + @Size(max = MAX_USERNAME_LENGTH) private String username; @NotNull - @LastModifiedDate private LocalDateTime issuedAt; - public StudentCode(VerificationCode code, School school, Member member, String username) { - this(null, code, school, member, username, null); + public StudentCode(VerificationCode code, School school, Member member, String username, LocalDateTime issuedAt) { + this(null, code, school, member, username, issuedAt); } public StudentCode(Long id, VerificationCode code, School school, Member member, String username, LocalDateTime issuedAt) { - validate(username); + validate(code, school, member, username); this.id = id; this.code = code; this.school = school; @@ -66,24 +69,50 @@ public StudentCode(Long id, VerificationCode code, School school, Member member, this.issuedAt = issuedAt; } - private void validate(String username) { - if (!StringUtils.hasText(username)) { - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); - } + private void validate(VerificationCode code, School school, Member member, String username) { + validateVerificationCode(code); + validateSchool(school); + validateMember(member); + validateUsername(username); + } + + private void validateVerificationCode(VerificationCode code) { + Validator.notNull(code, "validateVerificationCode"); + } + + private void validateSchool(School school) { + Validator.notNull(school, "school"); + } - if (username.length() > 255) { - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + private void validateMember(Member member) { + Validator.notNull(member, "member"); + } + + private void validateUsername(String username) { + String fieldName = "username"; + Validator.notBlank(username, fieldName); + Validator.maxLength(username, MAX_USERNAME_LENGTH, fieldName); + } + + public void reissue(StudentCode newStudentCode) { + if (newStudentCode.id != null) { + throw new UnexpectedException("새로 발급할 인증 코드는 식별자가 없어야 합니다."); + } + if (!canReissue(newStudentCode.issuedAt)) { + throw new TooManyRequestException(ErrorCode.TOO_FREQUENT_REQUESTS); } + this.code = newStudentCode.code; + this.school = newStudentCode.school; + this.username = newStudentCode.username; + this.issuedAt = newStudentCode.issuedAt; } - public boolean canReissue(LocalDateTime currentTime) { + private boolean canReissue(LocalDateTime currentTime) { return SECONDS.between(issuedAt, currentTime) > MIN_REQUEST_TERM_SECONDS; } - public void reissue(VerificationCode code, School school, String username) { - this.code = code; - this.school = school; - this.username = username; + public String getEmail() { + return username + "@" + school.getDomain(); } public Long getId() { diff --git a/backend/src/main/java/com/festago/student/domain/VerificationCode.java b/backend/src/main/java/com/festago/student/domain/VerificationCode.java index 4c5f751be..fe8d1e136 100644 --- a/backend/src/main/java/com/festago/student/domain/VerificationCode.java +++ b/backend/src/main/java/com/festago/student/domain/VerificationCode.java @@ -1,7 +1,7 @@ package com.festago.student.domain; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.ValidException; +import com.festago.common.util.Validator; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import java.util.regex.Pattern; @@ -23,30 +23,33 @@ public VerificationCode(String value) { } private void validate(String value) { - validateNull(value); + validateBlank(value); validateLength(value); validatePositive(value); } - private void validateNull(String value) { - if (value == null) { - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); - } + private void validateBlank(String value) { + Validator.notBlank(value, "VerificationCode"); } private void validateLength(String value) { if (value.length() != LENGTH) { - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + throw new ValidException("VerificationCode의 길이는 %d 이어야 합니다.".formatted(LENGTH)); } } private void validatePositive(String value) { if (!POSITIVE_REGEX.matcher(value).matches()) { - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + throw new ValidException("VerificationCode는 0~9의 양수 형식이어야 합니다."); } } public String getValue() { return value; } + + @Override + public String toString() { + return value; + } } diff --git a/backend/src/main/java/com/festago/student/domain/VerificationMailPayload.java b/backend/src/main/java/com/festago/student/domain/VerificationMailPayload.java deleted file mode 100644 index e6e159d06..000000000 --- a/backend/src/main/java/com/festago/student/domain/VerificationMailPayload.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.festago.student.domain; - -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; - -public class VerificationMailPayload { - - private VerificationCode code; - private String username; - private String domain; - - public VerificationMailPayload(VerificationCode code, String username, String domain) { - validate(code, username, domain); - this.code = code; - this.username = username; - this.domain = domain; - } - - private void validate(VerificationCode code, String username, String domain) { - if (code == null || username == null || domain == null) { - throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); - } - } - - public String getCode() { - return code.getValue(); - } - - public String getUsername() { - return username; - } - - public String getDomain() { - return domain; - } -} diff --git a/backend/src/main/java/com/festago/student/domain/validator/school/StudentSchoolDeleteValidator.java b/backend/src/main/java/com/festago/student/domain/validator/school/StudentSchoolDeleteValidator.java new file mode 100644 index 000000000..9dd3736d6 --- /dev/null +++ b/backend/src/main/java/com/festago/student/domain/validator/school/StudentSchoolDeleteValidator.java @@ -0,0 +1,24 @@ +package com.festago.student.domain.validator.school; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.school.domain.validator.SchoolDeleteValidator; +import com.festago.student.repository.StudentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class StudentSchoolDeleteValidator implements SchoolDeleteValidator { + + private final StudentRepository studentRepository; + + @Override + public void validate(Long schoolId) { + if (studentRepository.existsBySchoolId(schoolId)) { + throw new BadRequestException(ErrorCode.SCHOOL_DELETE_CONSTRAINT_EXISTS_STUDENT); + } + } +} diff --git a/backend/src/main/java/com/festago/student/dto/StudentResponse.java b/backend/src/main/java/com/festago/student/dto/StudentResponse.java new file mode 100644 index 000000000..2805e2fb6 --- /dev/null +++ b/backend/src/main/java/com/festago/student/dto/StudentResponse.java @@ -0,0 +1,22 @@ +package com.festago.student.dto; + +import com.festago.student.domain.Student; + +public record StudentResponse( + boolean isVerified, + StudentSchoolResponse school +) { + + private static final StudentResponse NOT_VERIFIED = new StudentResponse(false, null); + + public static StudentResponse verified(Student student) { + return new StudentResponse( + true, + StudentSchoolResponse.from(student.getSchool()) + ); + } + + public static StudentResponse notVerified() { + return NOT_VERIFIED; + } +} diff --git a/backend/src/main/java/com/festago/student/dto/StudentSchoolResponse.java b/backend/src/main/java/com/festago/student/dto/StudentSchoolResponse.java new file mode 100644 index 000000000..479fc5cd9 --- /dev/null +++ b/backend/src/main/java/com/festago/student/dto/StudentSchoolResponse.java @@ -0,0 +1,18 @@ +package com.festago.student.dto; + +import com.festago.school.domain.School; + +public record StudentSchoolResponse( + Long id, + String schoolName, + String domain +) { + + public static StudentSchoolResponse from(School school) { + return new StudentSchoolResponse( + school.getId(), + school.getName(), + school.getDomain() + ); + } +} diff --git a/backend/src/main/java/com/festago/student/infrastructure/GoogleMailClient.java b/backend/src/main/java/com/festago/student/infrastructure/GoogleMailClient.java index db4187cf6..3d212ff76 100644 --- a/backend/src/main/java/com/festago/student/infrastructure/GoogleMailClient.java +++ b/backend/src/main/java/com/festago/student/infrastructure/GoogleMailClient.java @@ -1,9 +1,10 @@ package com.festago.student.infrastructure; import com.festago.student.application.MailClient; -import com.festago.student.domain.VerificationMailPayload; +import java.util.function.Consumer; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; +import org.springframework.mail.MailMessage; import org.springframework.mail.MailSender; import org.springframework.mail.SimpleMailMessage; import org.springframework.scheduling.annotation.Async; @@ -23,16 +24,10 @@ public GoogleMailClient(MailSender mailSender, @Value("${spring.mail.username}") @Override @Async - public void send(VerificationMailPayload payload) { - SimpleMailMessage mail = new SimpleMailMessage(); - mail.setFrom(fromEmail); - mail.setTo(payload.getUsername() + "@" + payload.getDomain()); - mail.setSubject("[페스타고] 학생 이메일 인증 코드"); - mail.setText(""" - 페스타고 학생 이메일 인증 코드입니다. - Code는 다음과 같습니다. - %s - """.formatted(payload.getCode())); - mailSender.send(mail); + public void send(Consumer mailMessageConsumer) { + SimpleMailMessage mailMessage = new SimpleMailMessage(); + mailMessageConsumer.accept(mailMessage); + mailMessage.setFrom(fromEmail); + mailSender.send(mailMessage); } } diff --git a/backend/src/main/java/com/festago/student/infrastructure/MockMailClient.java b/backend/src/main/java/com/festago/student/infrastructure/MockMailClient.java index b2769917c..5221cd16f 100644 --- a/backend/src/main/java/com/festago/student/infrastructure/MockMailClient.java +++ b/backend/src/main/java/com/festago/student/infrastructure/MockMailClient.java @@ -1,8 +1,9 @@ package com.festago.student.infrastructure; import com.festago.student.application.MailClient; -import com.festago.student.domain.VerificationMailPayload; +import java.util.function.Consumer; import org.springframework.context.annotation.Profile; +import org.springframework.mail.MailMessage; import org.springframework.stereotype.Component; @Component @@ -10,7 +11,7 @@ public class MockMailClient implements MailClient { @Override - public void send(VerificationMailPayload payload) { + public void send(Consumer mailMessageConsumer) { // no-op } } diff --git a/backend/src/main/java/com/festago/student/presentation/StudentController.java b/backend/src/main/java/com/festago/student/presentation/StudentController.java new file mode 100644 index 000000000..7c8d620a5 --- /dev/null +++ b/backend/src/main/java/com/festago/student/presentation/StudentController.java @@ -0,0 +1,55 @@ +package com.festago.student.presentation; + +import com.festago.auth.annotation.Member; +import com.festago.student.application.StudentService; +import com.festago.student.dto.StudentResponse; +import com.festago.student.dto.StudentSendMailRequest; +import com.festago.student.dto.StudentVerificateRequest; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Deprecated(forRemoval = true) +@Hidden +@RestController +@RequestMapping("/students") +@Tag(name = "학생 요청") +@RequiredArgsConstructor +public class StudentController { + + private final StudentService studentService; + + @PostMapping("/send-verification") + @Operation(description = "학교 인증 이메일을 전송한다.", summary = "학생 인증 이메일 전송") + public ResponseEntity sendEmail(@Member Long memberId, + @RequestBody @Valid StudentSendMailRequest request) { + studentService.sendVerificationMail(memberId, request); + return ResponseEntity.ok() + .build(); + } + + @PostMapping("/verification") + @Operation(description = "학교 인증을 수행한다.", summary = "학생 인증 수행") + public ResponseEntity verify(@Member Long memberId, + @RequestBody @Valid StudentVerificateRequest request) { + studentService.verify(memberId, request); + return ResponseEntity.ok() + .build(); + } + + @GetMapping + @Operation(description = "학생 인증 정보를 조회한다.", summary = "학생 인증 정보 조회") + public ResponseEntity findVerification(@Member Long memberId) { + StudentResponse response = studentService.findVerification(memberId); + return ResponseEntity.ok() + .body(response); + } +} diff --git a/backend/src/main/java/com/festago/student/repository/StudentRepository.java b/backend/src/main/java/com/festago/student/repository/StudentRepository.java index f196c95ba..beae7509b 100644 --- a/backend/src/main/java/com/festago/student/repository/StudentRepository.java +++ b/backend/src/main/java/com/festago/student/repository/StudentRepository.java @@ -4,11 +4,13 @@ import com.festago.student.domain.Student; import org.springframework.data.jpa.repository.JpaRepository; -public interface StudentRepository extends JpaRepository { +public interface StudentRepository extends JpaRepository, StudentRepositoryCustom { boolean existsByMemberAndSchoolId(Member member, Long schoolId); boolean existsByUsernameAndSchoolId(String username, Long id); boolean existsByMemberId(Long id); + + boolean existsBySchoolId(Long schoolId); } diff --git a/backend/src/main/java/com/festago/student/repository/StudentRepositoryCustom.java b/backend/src/main/java/com/festago/student/repository/StudentRepositoryCustom.java new file mode 100644 index 000000000..045cfad4f --- /dev/null +++ b/backend/src/main/java/com/festago/student/repository/StudentRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.festago.student.repository; + +import com.festago.student.domain.Student; +import java.util.Optional; + +public interface StudentRepositoryCustom { + + Optional findByMemberIdWithFetch(Long memberId); +} diff --git a/backend/src/main/java/com/festago/student/repository/StudentRepositoryCustomImpl.java b/backend/src/main/java/com/festago/student/repository/StudentRepositoryCustomImpl.java new file mode 100644 index 000000000..14f183f3d --- /dev/null +++ b/backend/src/main/java/com/festago/student/repository/StudentRepositoryCustomImpl.java @@ -0,0 +1,23 @@ +package com.festago.student.repository; + +import static com.festago.school.domain.QSchool.school; +import static com.festago.student.domain.QStudent.student; + +import com.festago.student.domain.Student; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Optional; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class StudentRepositoryCustomImpl implements StudentRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findByMemberIdWithFetch(Long memberId) { + return Optional.ofNullable(queryFactory.selectFrom(student) + .innerJoin(student.school, school).fetchJoin() + .where(student.member.id.eq(memberId)) + .fetchOne()); + } +} diff --git a/backend/src/main/java/com/festago/ticket/application/TicketService.java b/backend/src/main/java/com/festago/ticket/application/TicketService.java index 74e72d5f4..fe9424e52 100644 --- a/backend/src/main/java/com/festago/ticket/application/TicketService.java +++ b/backend/src/main/java/com/festago/ticket/application/TicketService.java @@ -46,6 +46,6 @@ private Stage findStageById(Long stageId) { @Transactional(readOnly = true) public StageTicketsResponse findStageTickets(Long stageId) { - return StageTicketsResponse.from(ticketRepository.findAllByStageId(stageId)); + return StageTicketsResponse.from(ticketRepository.findAllByStageIdWithFetch(stageId)); } } diff --git a/backend/src/main/java/com/festago/ticket/domain/ReservationSequence.java b/backend/src/main/java/com/festago/ticket/domain/ReservationSequence.java new file mode 100644 index 000000000..97d28e128 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/domain/ReservationSequence.java @@ -0,0 +1,26 @@ +package com.festago.ticket.domain; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; + +public class ReservationSequence { + + private static final int MOST_FAST_SEQUENCE = 1; + + private final int value; + + public ReservationSequence(int value) { + validate(value); + this.value = value; + } + + private void validate(int sequence) { + if (sequence < MOST_FAST_SEQUENCE) { + throw new InternalServerException(ErrorCode.TICKET_SEQUENCE_DATA_ERROR); + } + } + + public int getValue() { + return value; + } +} diff --git a/backend/src/main/java/com/festago/ticket/domain/Ticket.java b/backend/src/main/java/com/festago/ticket/domain/Ticket.java index b371db699..ec2628939 100644 --- a/backend/src/main/java/com/festago/ticket/domain/Ticket.java +++ b/backend/src/main/java/com/festago/ticket/domain/Ticket.java @@ -3,11 +3,11 @@ import com.festago.common.domain.BaseTimeEntity; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; -import com.festago.member.domain.Member; +import com.festago.common.util.Validator; import com.festago.school.domain.School; import com.festago.stage.domain.Stage; -import com.festago.ticketing.domain.MemberTicket; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -47,6 +47,7 @@ public class Ticket extends BaseTimeEntity { @NotNull @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") private TicketType ticketType; @OneToOne(mappedBy = "ticket", optional = false, fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) @@ -70,7 +71,7 @@ public Ticket(Long id, Stage stage, TicketType ticketType, School school) { } public Ticket(Long id, Stage stage, TicketType ticketType, Long schoolId) { - validate(stage, ticketType); + validate(stage, ticketType, schoolId); this.id = id; this.stage = stage; this.ticketType = ticketType; @@ -78,15 +79,22 @@ public Ticket(Long id, Stage stage, TicketType ticketType, Long schoolId) { this.schoolId = schoolId; } - private void validate(Stage stage, TicketType ticketType) { - checkNotNull(stage, ticketType); + private void validate(Stage stage, TicketType ticketType, Long schoolId) { + validateStage(stage); + validateTicketType(ticketType); + validateSchoolId(schoolId); } - private void checkNotNull(Stage stage, TicketType ticketType) { - if (stage == null || - ticketType == null) { - throw new IllegalArgumentException("Ticket 은 허용되지 않은 null 값으로 생성할 수 없습니다."); - } + private void validateStage(Stage stage) { + Validator.notNull(stage, "stage"); + } + + private void validateTicketType(TicketType ticketType) { + Validator.notNull(ticketType, "ticketType"); + } + + private void validateSchoolId(Long schoolId) { + Validator.notNull(schoolId, "schoolId"); } public void addTicketEntryTime(LocalDateTime currentTime, LocalDateTime entryTime, int amount) { @@ -113,25 +121,30 @@ private void validateEntryTime(LocalDateTime currentTime, LocalDateTime entryTim } } - public MemberTicket createMemberTicket(Member member, int reservationSequence, LocalDateTime currentTime) { - if (stage.isStart(currentTime)) { - throw new BadRequestException(ErrorCode.TICKET_CANNOT_RESERVE_STAGE_START); - } - LocalDateTime entryTime = calculateEntryTime(reservationSequence); - return new MemberTicket(member, stage, reservationSequence, entryTime, ticketType); + public TicketReserveInfo extractTicketInfo(ReservationSequence sequence) { + LocalDateTime entryTime = calculateEntryTime(sequence); + return new TicketReserveInfo(stage, + sequence, + entryTime, + ticketType); } - private LocalDateTime calculateEntryTime(int reservationSequence) { + private LocalDateTime calculateEntryTime(ReservationSequence sequence) { int lastSequence = 0; + int sequenceValue = sequence.getValue(); for (TicketEntryTime ticketEntryTime : ticketEntryTimes) { lastSequence += ticketEntryTime.getAmount(); - if (reservationSequence <= lastSequence) { + if (sequenceValue <= lastSequence) { return ticketEntryTime.getEntryTime(); } } throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); } + public boolean alreadyStart(LocalDateTime currentTime) { + return stage.isStart(currentTime); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/festago/ticket/domain/TicketAmount.java b/backend/src/main/java/com/festago/ticket/domain/TicketAmount.java index 738ccfc6c..9665b4517 100644 --- a/backend/src/main/java/com/festago/ticket/domain/TicketAmount.java +++ b/backend/src/main/java/com/festago/ticket/domain/TicketAmount.java @@ -3,6 +3,7 @@ import com.festago.common.domain.BaseTimeEntity; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; +import com.festago.common.util.Validator; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; @@ -36,13 +37,7 @@ public TicketAmount(Ticket ticket) { } private void validate(Ticket ticket) { - checkNotNull(ticket); - } - - private void checkNotNull(Ticket ticket) { - if (ticket == null) { - throw new IllegalArgumentException("TicketAmount 는 허용되지 않은 null 값으로 생성할 수 없습니다."); - } + Validator.notNull(ticket, "ticket"); } public void increaseReservedAmount() { diff --git a/backend/src/main/java/com/festago/ticket/domain/TicketEntryTime.java b/backend/src/main/java/com/festago/ticket/domain/TicketEntryTime.java index f26cf7d4b..baf1dbb99 100644 --- a/backend/src/main/java/com/festago/ticket/domain/TicketEntryTime.java +++ b/backend/src/main/java/com/festago/ticket/domain/TicketEntryTime.java @@ -1,8 +1,7 @@ package com.festago.ticket.domain; import com.festago.common.domain.BaseTimeEntity; -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ErrorCode; +import com.festago.common.util.Validator; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -17,7 +16,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TicketEntryTime extends BaseTimeEntity implements Comparable { - private static final int MIN_TOTAL_AMOUNT = 1; + private static final int MIN_AMOUNT_VALUE = 1; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -26,7 +25,7 @@ public class TicketEntryTime extends BaseTimeEntity implements Comparable { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select ta from TicketAmount ta where ta.id = :ticketId") - Optional findByTicketIdForUpdate(@Param("ticketId") Long ticketId); +public interface TicketAmountRepository extends JpaRepository, TicketAmountRepositoryCustom { } diff --git a/backend/src/main/java/com/festago/ticket/repository/TicketAmountRepositoryCustom.java b/backend/src/main/java/com/festago/ticket/repository/TicketAmountRepositoryCustom.java new file mode 100644 index 000000000..8d99f6d6c --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/repository/TicketAmountRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.festago.ticket.repository; + +import com.festago.ticket.domain.TicketAmount; +import java.util.Optional; + +public interface TicketAmountRepositoryCustom { + + Optional findByTicketIdForUpdate(Long ticketId); +} diff --git a/backend/src/main/java/com/festago/ticket/repository/TicketAmountRepositoryCustomImpl.java b/backend/src/main/java/com/festago/ticket/repository/TicketAmountRepositoryCustomImpl.java new file mode 100644 index 000000000..38be5e634 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/repository/TicketAmountRepositoryCustomImpl.java @@ -0,0 +1,23 @@ +package com.festago.ticket.repository; + +import static com.festago.ticket.domain.QTicketAmount.ticketAmount; + +import com.festago.ticket.domain.TicketAmount; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class TicketAmountRepositoryCustomImpl implements TicketAmountRepositoryCustom{ + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findByTicketIdForUpdate(Long ticketId) { + return Optional.ofNullable(queryFactory.selectFrom(ticketAmount) + .where(ticketAmount.id.eq(ticketId)) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .fetchOne()); + } +} diff --git a/backend/src/main/java/com/festago/ticket/repository/TicketRepository.java b/backend/src/main/java/com/festago/ticket/repository/TicketRepository.java index 8c5ab50af..d15baaec8 100644 --- a/backend/src/main/java/com/festago/ticket/repository/TicketRepository.java +++ b/backend/src/main/java/com/festago/ticket/repository/TicketRepository.java @@ -3,23 +3,10 @@ import com.festago.stage.domain.Stage; import com.festago.ticket.domain.Ticket; import com.festago.ticket.domain.TicketType; -import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -public interface TicketRepository extends JpaRepository { - - List findAllByStageId(Long stageId); +public interface TicketRepository extends JpaRepository, TicketRepositoryCustom { Optional findByTicketTypeAndStage(TicketType ticketType, Stage stage); - - @Query(""" - SELECT t FROM Ticket t - JOIN FETCH t.stage s - JOIN FETCH t.ticketEntryTimes et - WHERE t.id = :ticketId - """) - Optional findByIdWithFetch(@Param("ticketId") Long ticketId); } diff --git a/backend/src/main/java/com/festago/ticket/repository/TicketRepositoryCustom.java b/backend/src/main/java/com/festago/ticket/repository/TicketRepositoryCustom.java new file mode 100644 index 000000000..d682267b6 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/repository/TicketRepositoryCustom.java @@ -0,0 +1,12 @@ +package com.festago.ticket.repository; + +import com.festago.ticket.domain.Ticket; +import java.util.List; +import java.util.Optional; + +public interface TicketRepositoryCustom { + + List findAllByStageIdWithFetch(Long stageId); + + Optional findByIdWithFetch(Long ticketId); +} diff --git a/backend/src/main/java/com/festago/ticket/repository/TicketRepositoryCustomImpl.java b/backend/src/main/java/com/festago/ticket/repository/TicketRepositoryCustomImpl.java new file mode 100644 index 000000000..720a3b8a8 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/repository/TicketRepositoryCustomImpl.java @@ -0,0 +1,34 @@ +package com.festago.ticket.repository; + +import static com.festago.stage.domain.QStage.stage; +import static com.festago.ticket.domain.QTicket.ticket; +import static com.festago.ticket.domain.QTicketEntryTime.ticketEntryTime; + +import com.festago.ticket.domain.Ticket; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class TicketRepositoryCustomImpl implements TicketRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByStageIdWithFetch(Long stageId) { + return queryFactory.selectFrom(ticket) + .innerJoin(ticket.ticketAmount).fetchJoin() + .where(ticket.stage.id.eq(stageId)) + .fetch(); + } + + @Override + public Optional findByIdWithFetch(Long ticketId) { + return Optional.ofNullable(queryFactory.selectFrom(ticket) + .innerJoin(ticket.stage, stage).fetchJoin() + .innerJoin(ticket.ticketEntryTimes, ticketEntryTime).fetchJoin() + .where(ticket.id.eq(ticketId)) + .fetchOne()); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/application/MemberTicketService.java b/backend/src/main/java/com/festago/ticketing/application/MemberTicketService.java index bae73a20e..9e598031b 100644 --- a/backend/src/main/java/com/festago/ticketing/application/MemberTicketService.java +++ b/backend/src/main/java/com/festago/ticketing/application/MemberTicketService.java @@ -1,13 +1,10 @@ package com.festago.ticketing.application; import static java.util.Comparator.comparing; -import static java.util.stream.Collectors.collectingAndThen; -import static java.util.stream.Collectors.toList; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; import com.festago.common.exception.NotFoundException; -import com.festago.member.repository.MemberRepository; import com.festago.ticketing.domain.MemberTicket; import com.festago.ticketing.dto.MemberTicketResponse; import com.festago.ticketing.dto.MemberTicketsResponse; @@ -27,31 +24,30 @@ public class MemberTicketService { private final MemberTicketRepository memberTicketRepository; - private final MemberRepository memberRepository; private final Clock clock; @Transactional(readOnly = true) public MemberTicketResponse findById(Long memberId, Long memberTicketId) { - validateMemberId(memberId); MemberTicket memberTicket = memberTicketRepository.findById(memberTicketId) .orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_TICKET_NOT_FOUND)); + validateOwner(memberTicket, memberId); + return MemberTicketResponse.from(memberTicket); + } + + private void validateOwner(MemberTicket memberTicket, Long memberId) { if (!memberTicket.isOwner(memberId)) { throw new BadRequestException(ErrorCode.NOT_MEMBER_TICKET_OWNER); } - return MemberTicketResponse.from(memberTicket); } @Transactional(readOnly = true) public MemberTicketsResponse findAll(Long memberId, Pageable pageable) { - validateMemberId(memberId); List memberTickets = memberTicketRepository.findAllByOwnerId(memberId, pageable); - return memberTickets.stream() - .collect(collectingAndThen(toList(), MemberTicketsResponse::from)); + return MemberTicketsResponse.from(memberTickets); } @Transactional(readOnly = true) public MemberTicketsResponse findCurrent(Long memberId, Pageable pageable) { - validateMemberId(memberId); List memberTickets = memberTicketRepository.findAllByOwnerId(memberId, pageable); return MemberTicketsResponse.from(filterCurrentMemberTickets(memberTickets)); } @@ -68,9 +64,4 @@ private List filterCurrentMemberTickets(List memberT private Duration calculateTimeGap(MemberTicket memberTicket, LocalDateTime time) { return Duration.between(memberTicket.getEntryTime(), time).abs(); } - - private void validateMemberId(Long memberId) { - memberRepository.findById(memberId) - .orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND)); - } } diff --git a/backend/src/main/java/com/festago/ticketing/application/TicketingService.java b/backend/src/main/java/com/festago/ticketing/application/TicketingService.java index 6b0372a7c..835ab5b28 100644 --- a/backend/src/main/java/com/festago/ticketing/application/TicketingService.java +++ b/backend/src/main/java/com/festago/ticketing/application/TicketingService.java @@ -6,6 +6,7 @@ import com.festago.member.domain.Member; import com.festago.member.repository.MemberRepository; import com.festago.student.repository.StudentRepository; +import com.festago.ticket.domain.ReservationSequence; import com.festago.ticket.domain.Ticket; import com.festago.ticket.domain.TicketAmount; import com.festago.ticket.domain.TicketType; @@ -38,21 +39,13 @@ public TicketingResponse ticketing(Long memberId, TicketingRequest request) { Member member = findMemberById(memberId); validateAlreadyReserved(member, ticket); validateStudent(member, ticket); - int reserveSequence = getReserveSequence(request.ticketId()); - MemberTicket memberTicket = ticket.createMemberTicket(member, reserveSequence, LocalDateTime.now(clock)); + ReservationSequence sequence = getReserveSequence(request.ticketId()); + MemberTicket memberTicket = MemberTicket.createMemberTicket(ticket, member, sequence, + LocalDateTime.now(clock)); memberTicketRepository.save(memberTicket); return TicketingResponse.from(memberTicket); } - private void validateStudent(Member member, Ticket ticket) { - if (ticket.getTicketType() != TicketType.STUDENT) { - return; - } - if (!studentRepository.existsByMemberAndSchoolId(member, ticket.getSchoolId())) { - throw new BadRequestException(ErrorCode.NEED_STUDENT_VERIFICATION); - } - } - private Ticket findTicketById(Long ticketId) { return ticketRepository.findByIdWithFetch(ticketId) .orElseThrow(() -> new NotFoundException(ErrorCode.TICKET_NOT_FOUND)); @@ -69,10 +62,19 @@ private void validateAlreadyReserved(Member member, Ticket ticket) { } } - private int getReserveSequence(Long ticketId) { + private void validateStudent(Member member, Ticket ticket) { + if (ticket.getTicketType() != TicketType.STUDENT) { + return; + } + if (!studentRepository.existsByMemberAndSchoolId(member, ticket.getSchoolId())) { + throw new BadRequestException(ErrorCode.NEED_STUDENT_VERIFICATION); + } + } + + private ReservationSequence getReserveSequence(Long ticketId) { TicketAmount ticketAmount = findTicketAmountById(ticketId); ticketAmount.increaseReservedAmount(); - return ticketAmount.getReservedAmount(); + return new ReservationSequence(ticketAmount.getReservedAmount()); } private TicketAmount findTicketAmountById(Long ticketId) { diff --git a/backend/src/main/java/com/festago/ticketing/domain/EntryState.java b/backend/src/main/java/com/festago/ticketing/domain/EntryState.java index 76c68a288..a0f172ddc 100644 --- a/backend/src/main/java/com/festago/ticketing/domain/EntryState.java +++ b/backend/src/main/java/com/festago/ticketing/domain/EntryState.java @@ -1,7 +1,6 @@ package com.festago.ticketing.domain; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.UnexpectedException; public enum EntryState { BEFORE_ENTRY(0), @@ -16,18 +15,18 @@ public enum EntryState { } public static EntryState from(Integer index) { - validate(index); + validateNull(index); return switch (index) { case 0 -> BEFORE_ENTRY; case 1 -> AFTER_ENTRY; case 2 -> AWAY; - default -> throw new InternalServerException(ErrorCode.INVALID_ENTRY_STATE_INDEX); + default -> throw new UnexpectedException("entryState의 인덱스가 올바르지 않습니다. index: " + index); }; } - private static void validate(Integer index) { + private static void validateNull(Integer index) { if (index == null) { - throw new InternalServerException(ErrorCode.INVALID_ENTRY_STATE_INDEX); + throw new UnexpectedException("entryState의 인덱스는 null이 될 수 없습니다."); } } diff --git a/backend/src/main/java/com/festago/ticketing/domain/MemberTicket.java b/backend/src/main/java/com/festago/ticketing/domain/MemberTicket.java index ec5f881f2..303387a78 100644 --- a/backend/src/main/java/com/festago/ticketing/domain/MemberTicket.java +++ b/backend/src/main/java/com/festago/ticketing/domain/MemberTicket.java @@ -1,9 +1,16 @@ package com.festago.ticketing.domain; import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.util.Validator; import com.festago.member.domain.Member; import com.festago.stage.domain.Stage; +import com.festago.ticket.domain.ReservationSequence; +import com.festago.ticket.domain.Ticket; +import com.festago.ticket.domain.TicketReserveInfo; import com.festago.ticket.domain.TicketType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -24,6 +31,7 @@ public class MemberTicket extends BaseTimeEntity { private static final long ENTRY_LIMIT_HOUR = 24; + private static final int MIN_NUMBER_VALUE = 1; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -31,6 +39,7 @@ public class MemberTicket extends BaseTimeEntity { @NotNull @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") private EntryState entryState = EntryState.BEFORE_ENTRY; @NotNull @@ -41,7 +50,7 @@ public class MemberTicket extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) private Stage stage; - @Min(value = 0) + @Min(value = MIN_NUMBER_VALUE) private int number; @NotNull @@ -49,6 +58,7 @@ public class MemberTicket extends BaseTimeEntity { @NotNull @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") private TicketType ticketType; public MemberTicket(Member owner, Stage stage, int number, LocalDateTime entryTime, TicketType ticketType) { @@ -66,24 +76,52 @@ public MemberTicket(Long id, Member owner, Stage stage, int number, LocalDateTim this.ticketType = ticketType; } + public static MemberTicket createMemberTicket(Ticket ticket, Member member, ReservationSequence sequence, + LocalDateTime currentTime) { + if (ticket.alreadyStart(currentTime)) { + throw new BadRequestException(ErrorCode.TICKET_CANNOT_RESERVE_STAGE_START); + } + + TicketReserveInfo ticketReserveInfo = extractTicketInfo(ticket, sequence); + return new MemberTicket( + member, + ticketReserveInfo.stage(), + ticketReserveInfo.sequence().getValue(), + ticketReserveInfo.entryTime(), + ticketReserveInfo.ticketType() + ); + } + + private static TicketReserveInfo extractTicketInfo(Ticket ticket, ReservationSequence sequence) { + return ticket.extractTicketInfo(sequence); + } + private void validate(Member owner, Stage stage, int number, LocalDateTime entryTime, TicketType ticketType) { - checkNotNull(owner, stage, entryTime, ticketType); - checkScope(number); + validateOwner(owner); + validateStage(stage); + validateNumber(number); + validateEntryTime(entryTime); + validateTicketType(ticketType); } - private void checkNotNull(Member owner, Stage stage, LocalDateTime entryTime, TicketType ticketType) { - if (owner == null || - stage == null || - entryTime == null || - ticketType == null) { - throw new IllegalArgumentException("MemberTicket 은 허용되지 않은 null 값으로 생성할 수 없습니다."); - } + private void validateOwner(Member owner) { + Validator.notNull(owner, "owner"); } - private void checkScope(int number) { - if (number < 0) { - throw new IllegalArgumentException("MemberTicket 의 필드로 허용된 범위를 넘은 column 을 넣을 수 없습니다."); - } + private void validateStage(Stage stage) { + Validator.notNull(stage, "stage"); + } + + private void validateNumber(int number) { + Validator.minValue(number, MIN_NUMBER_VALUE, "number"); + } + + private void validateEntryTime(LocalDateTime entryTime) { + Validator.notNull(entryTime, "entryTime"); + } + + private void validateTicketType(TicketType ticketType) { + Validator.notNull(ticketType, "ticketType"); } public void changeState(EntryState originState) { diff --git a/backend/src/main/java/com/festago/ticketing/dto/MemberTicketFestivalResponse.java b/backend/src/main/java/com/festago/ticketing/dto/MemberTicketFestivalResponse.java index 0b3779cf0..3d44b6796 100644 --- a/backend/src/main/java/com/festago/ticketing/dto/MemberTicketFestivalResponse.java +++ b/backend/src/main/java/com/festago/ticketing/dto/MemberTicketFestivalResponse.java @@ -11,6 +11,6 @@ public static MemberTicketFestivalResponse from(Festival festival) { return new MemberTicketFestivalResponse( festival.getId(), festival.getName(), - festival.getThumbnail()); + festival.getPosterImageUrl()); } } diff --git a/backend/src/main/java/com/festago/ticketing/dto/MemberTicketResponse.java b/backend/src/main/java/com/festago/ticketing/dto/MemberTicketResponse.java index bd41105be..1128cac33 100644 --- a/backend/src/main/java/com/festago/ticketing/dto/MemberTicketResponse.java +++ b/backend/src/main/java/com/festago/ticketing/dto/MemberTicketResponse.java @@ -1,7 +1,6 @@ package com.festago.ticketing.dto; import com.festago.stage.domain.Stage; -import com.festago.stage.dto.StageResponse; import com.festago.ticketing.domain.EntryState; import com.festago.ticketing.domain.MemberTicket; import java.time.LocalDateTime; @@ -12,10 +11,9 @@ public record MemberTicketResponse( LocalDateTime entryTime, EntryState state, LocalDateTime reservedAt, - StageResponse stage, - MemberTicketFestivalResponse festival) { - - private static final MemberTicketResponse EMPTY = new MemberTicketResponse(-1L, null, null, null, null, null, null); +// StageResponse stage, // DTO 객체 새롭게 만들 것 + MemberTicketFestivalResponse festival +) { public static MemberTicketResponse from(MemberTicket memberTicket) { Stage stage = memberTicket.getStage(); @@ -25,11 +23,6 @@ public static MemberTicketResponse from(MemberTicket memberTicket) { memberTicket.getEntryTime(), memberTicket.getEntryState(), memberTicket.getCreatedAt(), - StageResponse.from(stage), MemberTicketFestivalResponse.from(stage.getFestival())); } - - public static MemberTicketResponse empty() { - return EMPTY; - } } diff --git a/backend/src/main/java/com/festago/presentation/MemberTicketController.java b/backend/src/main/java/com/festago/ticketing/presentation/MemberTicketController.java similarity index 84% rename from backend/src/main/java/com/festago/presentation/MemberTicketController.java rename to backend/src/main/java/com/festago/ticketing/presentation/MemberTicketController.java index ba7cda57a..8aa55849a 100644 --- a/backend/src/main/java/com/festago/presentation/MemberTicketController.java +++ b/backend/src/main/java/com/festago/ticketing/presentation/MemberTicketController.java @@ -1,14 +1,13 @@ -package com.festago.presentation; +package com.festago.ticketing.presentation; import com.festago.auth.annotation.Member; -import com.festago.entry.application.EntryService; -import com.festago.entry.dto.EntryCodeResponse; import com.festago.ticketing.application.MemberTicketService; import com.festago.ticketing.application.TicketingService; import com.festago.ticketing.dto.MemberTicketResponse; import com.festago.ticketing.dto.MemberTicketsResponse; import com.festago.ticketing.dto.TicketingRequest; import com.festago.ticketing.dto.TicketingResponse; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -26,6 +25,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Deprecated(forRemoval = true) +@Hidden @RestController @SecurityRequirement(name = "bearerAuth") @RequestMapping("/member-tickets") @@ -33,19 +34,9 @@ @RequiredArgsConstructor public class MemberTicketController { - private final EntryService entryService; private final MemberTicketService memberTicketService; private final TicketingService ticketingService; - @PostMapping("/{memberTicketId}/qr") - @Operation(description = "티켓 제시용 QR 코드를 생성한다.", summary = "티켓 제시용 QR 생성") - public ResponseEntity createQR(@Member Long memberId, - @PathVariable Long memberTicketId) { - EntryCodeResponse response = entryService.createEntryCode(memberId, memberTicketId); - return ResponseEntity.ok() - .body(response); - } - @PostMapping @Operation(description = "티켓을 예매한다.", summary = "티켓 예매") public ResponseEntity ticketing(@Member Long memberId, diff --git a/backend/src/main/java/com/festago/upload/application/ImageFileUploadService.java b/backend/src/main/java/com/festago/upload/application/ImageFileUploadService.java new file mode 100644 index 000000000..9f6391b70 --- /dev/null +++ b/backend/src/main/java/com/festago/upload/application/ImageFileUploadService.java @@ -0,0 +1,65 @@ +package com.festago.upload.application; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.util.Validator; +import com.festago.upload.domain.FileExtension; +import com.festago.upload.domain.FileOwnerType; +import com.festago.upload.domain.StorageClient; +import com.festago.upload.domain.UploadFile; +import com.festago.upload.dto.FileUploadResult; +import com.festago.upload.repository.UploadFileRepository; +import com.festago.upload.util.FileNameExtensionParser; +import jakarta.annotation.Nullable; +import java.util.EnumSet; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +// 명시적으로 @Transactional 사용하지 않음 +public class ImageFileUploadService { + + private static final int MAX_FILE_SIZE = 2_000_000; // 2MB + private static final Set ALLOW_IMAGE_EXTENSION = EnumSet.of(FileExtension.JPG, FileExtension.JPEG, FileExtension.PNG); + + private final StorageClient storageClient; + private final UploadFileRepository uploadFileRepository; + + public FileUploadResult upload(MultipartFile image, @Nullable Long ownerId, @Nullable FileOwnerType ownerType) { + validate(image); + UploadFile uploadImage = storageClient.storage(image); + if (ownerId != null && ownerType != null) { + uploadImage.changeAssigned(ownerId, ownerType); + } + + uploadFileRepository.save(uploadImage); + + return new FileUploadResult(uploadImage.getId(), uploadImage.getUploadUri()); + } + + private void validate(MultipartFile image) { + validateSize(image.getSize()); + validateExtension(image.getOriginalFilename()); + } + + private void validateSize(long imageSize) { + Validator.maxValue(imageSize, MAX_FILE_SIZE, "imageSize"); + } + + private void validateExtension(String imageName) { + Validator.notBlank(imageName, "imageName"); + String extension = FileNameExtensionParser.parse(imageName); + for (FileExtension allowExtension : ALLOW_IMAGE_EXTENSION) { + if (allowExtension.match(extension)) { + return; + } + } + log.info("허용되지 않은 확장자에 대한 이미지 업로드 요청이 있습니다. fileName={}, extension={}", imageName, extension); + throw new BadRequestException(ErrorCode.NOT_SUPPORT_FILE_EXTENSION); + } +} diff --git a/backend/src/main/java/com/festago/upload/application/UploadFileDeleteService.java b/backend/src/main/java/com/festago/upload/application/UploadFileDeleteService.java new file mode 100644 index 000000000..05d4d6a80 --- /dev/null +++ b/backend/src/main/java/com/festago/upload/application/UploadFileDeleteService.java @@ -0,0 +1,38 @@ +package com.festago.upload.application; + +import com.festago.upload.domain.StorageClient; +import com.festago.upload.domain.UploadFile; +import com.festago.upload.domain.UploadStatus; +import com.festago.upload.repository.UploadFileRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class UploadFileDeleteService { + + private final StorageClient storageClient; + private final UploadFileRepository uploadFileRepository; + private final Clock clock; + + public void deleteAbandonedStatusWithPeriod(LocalDateTime startTime, LocalDateTime endTime) { + List uploadFiles = uploadFileRepository.findByCreatedAtBetweenAndStatus(startTime, endTime, UploadStatus.ABANDONED); + deleteUploadFiles(uploadFiles); + } + + private void deleteUploadFiles(List uploadFiles) { + storageClient.delete(uploadFiles); + uploadFileRepository.deleteByIn(uploadFiles); + } + + public void deleteOldUploadedStatus() { + LocalDateTime yesterday = LocalDateTime.now(clock).minusDays(1); + List uploadFiles = uploadFileRepository.findByCreatedAtBeforeAndStatus(yesterday, UploadStatus.UPLOADED); + deleteUploadFiles(uploadFiles); + } +} diff --git a/backend/src/main/java/com/festago/upload/application/UploadFileStatusChangeService.java b/backend/src/main/java/com/festago/upload/application/UploadFileStatusChangeService.java new file mode 100644 index 000000000..a281e9f21 --- /dev/null +++ b/backend/src/main/java/com/festago/upload/application/UploadFileStatusChangeService.java @@ -0,0 +1,68 @@ +package com.festago.upload.application; + +import static java.util.stream.Collectors.toSet; + +import com.festago.upload.domain.FileOwnerType; +import com.festago.upload.domain.UploadFile; +import com.festago.upload.repository.UploadFileRepository; +import com.festago.upload.util.UriUploadFileIdParser; +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class UploadFileStatusChangeService { + + private final UploadFileRepository uploadFileRepository; + + /** + * 인자로 들어오는 fileUris에 해당하는 UploadFile을 모두 찾아 해당 UploadFile의 주인을 설정하고, ATTACHED 상태로 변경한다.
+ * + * @param ownerId 상태를 변경할 UploadFile의 주인 식별자 + * @param ownerType 상태를 변경할 UploadFile의 주인 타입 + * @param fileUris 주인을 설정하고 ATTACHED 상태로 변경할 UploadFileUri 목록 + */ + public void changeAttached(Long ownerId, FileOwnerType ownerType, Collection fileUris) { + Set uploadFileIds = parseFileIds(fileUris); + uploadFileRepository.findByIdIn(uploadFileIds) + .forEach(uploadFile -> uploadFile.changeAttached(ownerId, ownerType)); + } + + private Set parseFileIds(Collection fileUris) { + return fileUris.stream() + .map(UriUploadFileIdParser::parse) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(toSet()); + } + + /** + * 인자로 들어오는 ownerId와 ownerType에 해당하는 UploadFile을 모두 찾고 상태를 새롭게 변경한다.
+ * + * @param ownerId 상태를 변경할 UploadFile의 주인 식별자 + * @param ownerType 상태를 변경할 UploadFile의 주인 타입 + * @param fileUris 새롭게 변경된 UploadFileUri 목록 + */ + public void changeRenewal(Long ownerId, FileOwnerType ownerType, Collection fileUris) { + Set uploadFileIds = parseFileIds(fileUris); + uploadFileRepository.findAllByOwnerIdAndOwnerType(ownerId, ownerType) + .forEach(uploadFile -> uploadFile.renewalStatus(ownerId, ownerType, uploadFileIds)); + } + + /** + * 인자로 들어오는 ownerId와 ownerType에 해당하는 UploadFile을 모두 찾고 ABANDONED 상태로 변경한다. + * + * @param ownerId 상태를 변경할 UploadFile의 주인 식별자 + * @param ownerType 상태를 변경할 UploadFile의 주인 타입 + */ + public void changeAllAbandoned(Long ownerId, FileOwnerType ownerType) { + uploadFileRepository.findAllByOwnerIdAndOwnerType(ownerId, ownerType) + .forEach(UploadFile::changeAbandoned); + } +} diff --git a/backend/src/main/java/com/festago/upload/application/artist/AsyncArtistUploadImagesStatusChangeEventListener.java b/backend/src/main/java/com/festago/upload/application/artist/AsyncArtistUploadImagesStatusChangeEventListener.java new file mode 100644 index 000000000..8a6c8f86c --- /dev/null +++ b/backend/src/main/java/com/festago/upload/application/artist/AsyncArtistUploadImagesStatusChangeEventListener.java @@ -0,0 +1,46 @@ +package com.festago.upload.application.artist; + +import static com.festago.upload.domain.FileOwnerType.ARTIST; + +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.event.ArtistCreatedEvent; +import com.festago.artist.dto.event.ArtistDeletedEvent; +import com.festago.artist.dto.event.ArtistUpdatedEvent; +import com.festago.upload.application.UploadFileStatusChangeService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Async +@Component +@Transactional +@RequiredArgsConstructor +public class AsyncArtistUploadImagesStatusChangeEventListener { + + private final UploadFileStatusChangeService uploadFileStatusChangeService; + + @TransactionalEventListener(value = ArtistCreatedEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void changeAttachedStatusArtistImagesEventHandler(ArtistCreatedEvent event) { + Artist artist = event.artist(); + Long artistId = artist.getId(); + List imageUris = List.of(artist.getProfileImage(), artist.getBackgroundImageUrl()); + uploadFileStatusChangeService.changeAttached(artistId, ARTIST, imageUris); + } + + @TransactionalEventListener(value = ArtistUpdatedEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void changeRenewalStatusArtistImagesEventHandler(ArtistUpdatedEvent event) { + Artist artist = event.artist(); + Long artistId = artist.getId(); + List imageUris = List.of(artist.getProfileImage(), artist.getBackgroundImageUrl()); + uploadFileStatusChangeService.changeRenewal(artistId, ARTIST, imageUris); + } + + @TransactionalEventListener(value = ArtistDeletedEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void changeAbandonedStatusArtistImagesEventHandler(ArtistDeletedEvent event) { + uploadFileStatusChangeService.changeAllAbandoned(event.artistId(), ARTIST); + } +} diff --git a/backend/src/main/java/com/festago/upload/application/festival/AsyncFestivalUploadImagesStatusChangeEventListener.java b/backend/src/main/java/com/festago/upload/application/festival/AsyncFestivalUploadImagesStatusChangeEventListener.java new file mode 100644 index 000000000..332a0ed33 --- /dev/null +++ b/backend/src/main/java/com/festago/upload/application/festival/AsyncFestivalUploadImagesStatusChangeEventListener.java @@ -0,0 +1,46 @@ +package com.festago.upload.application.festival; + +import static com.festago.upload.domain.FileOwnerType.FESTIVAL; + +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.event.FestivalCreatedEvent; +import com.festago.festival.dto.event.FestivalDeletedEvent; +import com.festago.festival.dto.event.FestivalUpdatedEvent; +import com.festago.upload.application.UploadFileStatusChangeService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Async +@Component +@Transactional +@RequiredArgsConstructor +public class AsyncFestivalUploadImagesStatusChangeEventListener { + + private final UploadFileStatusChangeService uploadFileStatusChangeService; + + @TransactionalEventListener(value = FestivalCreatedEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void changeAttachedStatusFestivalImagesEventHandler(FestivalCreatedEvent event) { + Festival festival = event.festival(); + Long festivalId = festival.getId(); + List imageUris = List.of(festival.getPosterImageUrl()); + uploadFileStatusChangeService.changeAttached(festivalId, FESTIVAL, imageUris); + } + + @TransactionalEventListener(value = FestivalUpdatedEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void changeRenewalStatusFestivalImagesEventHandler(FestivalUpdatedEvent event) { + Festival festival = event.festival(); + Long festivalId = festival.getId(); + List imageUris = List.of(festival.getPosterImageUrl()); + uploadFileStatusChangeService.changeRenewal(festivalId, FESTIVAL, imageUris); + } + + @TransactionalEventListener(value = FestivalDeletedEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void changeAbandonedStatusFestivalImagesEventHandler(FestivalDeletedEvent event) { + uploadFileStatusChangeService.changeAllAbandoned(event.festivalId(), FESTIVAL); + } +} diff --git a/backend/src/main/java/com/festago/upload/application/school/AsyncSchoolUploadImagesStatusChangeEventListener.java b/backend/src/main/java/com/festago/upload/application/school/AsyncSchoolUploadImagesStatusChangeEventListener.java new file mode 100644 index 000000000..184f72251 --- /dev/null +++ b/backend/src/main/java/com/festago/upload/application/school/AsyncSchoolUploadImagesStatusChangeEventListener.java @@ -0,0 +1,46 @@ +package com.festago.upload.application.school; + +import static com.festago.upload.domain.FileOwnerType.SCHOOL; + +import com.festago.school.domain.School; +import com.festago.school.dto.event.SchoolCreatedEvent; +import com.festago.school.dto.event.SchoolDeletedEvent; +import com.festago.school.dto.event.SchoolUpdatedEvent; +import com.festago.upload.application.UploadFileStatusChangeService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Async +@Component +@Transactional +@RequiredArgsConstructor +public class AsyncSchoolUploadImagesStatusChangeEventListener { + + private final UploadFileStatusChangeService uploadFileStatusChangeService; + + @TransactionalEventListener(value = SchoolCreatedEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void changeAttachedStatusSchoolImagesEventHandler(SchoolCreatedEvent event) { + School school = event.school(); + Long schoolId = school.getId(); + List imageUris = List.of(school.getBackgroundUrl(), school.getLogoUrl()); + uploadFileStatusChangeService.changeAttached(schoolId, SCHOOL, imageUris); + } + + @TransactionalEventListener(value = SchoolUpdatedEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void changeRenewalStatusSchoolImagesEventHandler(SchoolUpdatedEvent event) { + School school = event.school(); + Long schoolId = school.getId(); + List imageUris = List.of(school.getBackgroundUrl(), school.getLogoUrl()); + uploadFileStatusChangeService.changeRenewal(schoolId, SCHOOL, imageUris); + } + + @TransactionalEventListener(value = SchoolDeletedEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void changeAbandonedStatusSchoolImagesEventHandler(SchoolDeletedEvent event) { + uploadFileStatusChangeService.changeAllAbandoned(event.schoolId(), SCHOOL); + } +} diff --git a/backend/src/main/java/com/festago/upload/domain/FileExtension.java b/backend/src/main/java/com/festago/upload/domain/FileExtension.java new file mode 100644 index 000000000..b5387464c --- /dev/null +++ b/backend/src/main/java/com/festago/upload/domain/FileExtension.java @@ -0,0 +1,42 @@ +package com.festago.upload.domain; + +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +@RequiredArgsConstructor +public enum FileExtension { + JPG(".jpg", MimeTypeUtils.IMAGE_JPEG), + JPEG(".jpeg", MimeTypeUtils.IMAGE_JPEG), + PNG(".png", MimeTypeUtils.IMAGE_PNG), + NONE("", MimeTypeUtils.APPLICATION_OCTET_STREAM), + ; + + private final String value; + private final MimeType mimeType; + + public String getValue() { + return value; + } + + public MimeType getMimeType() { + return mimeType; + } + + public boolean match(String value) { + return Objects.equals(this.value, value); + } + + public static FileExtension from(String mimeType) { + if (mimeType == null || mimeType.isBlank()) { + return NONE; + } + for (FileExtension extension : values()) { + if (Objects.equals(extension.mimeType.toString(), mimeType)) { + return extension; + } + } + return NONE; + } +} diff --git a/backend/src/main/java/com/festago/upload/domain/FileOwnerType.java b/backend/src/main/java/com/festago/upload/domain/FileOwnerType.java new file mode 100644 index 000000000..2776f6bbb --- /dev/null +++ b/backend/src/main/java/com/festago/upload/domain/FileOwnerType.java @@ -0,0 +1,10 @@ +package com.festago.upload.domain; + +public enum FileOwnerType { + SCHOOL, + ARTIST, + FESTIVAL, + SOCIAL_MEDIA, + MEMBER, + ; +} diff --git a/backend/src/main/java/com/festago/upload/domain/StorageClient.java b/backend/src/main/java/com/festago/upload/domain/StorageClient.java new file mode 100644 index 000000000..65914f4cd --- /dev/null +++ b/backend/src/main/java/com/festago/upload/domain/StorageClient.java @@ -0,0 +1,24 @@ +package com.festago.upload.domain; + +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +public interface StorageClient { + + /** + * MultipartFile을 보관(영속)하는 메서드
업로드 작업이 끝나면, 업로드한 파일의 정보를 가진 UploadStatus.UPLOADED 상태의 UploadFile를 반환해야 한다. + *
반환된 UploadFile을 영속하는 책임은 해당 메서드를 사용하는 클라이언트가 구현해야 한다.
+ * + * @param file 업로드 할 MultipartFile + * @return UploadStatus.PENDING 상태의 영속되지 않은 UploadFile 엔티티 + */ + UploadFile storage(MultipartFile file); + + /** + * 업로드 파일을 삭제하는 메서드
삭제 작업이 끝나면, UploadFile이 가진 정보에 대한 업로드 된 파일이 없으므로, 인자로 들어온 UploadFiles를 삭제해야 한다.
삭제가 + * 끝나고 UploadFile을 삭제하는 책임은 해당 메서드를 사용하는 클라이언트가 구현해야 한다.
+ * + * @param uploadFiles 삭제하려는 업로드 된 파일의 정보가 담긴 UploadFile 목록 + */ + void delete(List uploadFiles); +} diff --git a/backend/src/main/java/com/festago/upload/domain/URIStringConverter.java b/backend/src/main/java/com/festago/upload/domain/URIStringConverter.java new file mode 100644 index 000000000..c18ff0b77 --- /dev/null +++ b/backend/src/main/java/com/festago/upload/domain/URIStringConverter.java @@ -0,0 +1,19 @@ +package com.festago.upload.domain; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.net.URI; + +@Converter +public class URIStringConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(URI attribute) { + return attribute.toString(); + } + + @Override + public URI convertToEntityAttribute(String dbData) { + return URI.create(dbData); + } +} diff --git a/backend/src/main/java/com/festago/upload/domain/UploadFile.java b/backend/src/main/java/com/festago/upload/domain/UploadFile.java new file mode 100644 index 000000000..d63d61e0b --- /dev/null +++ b/backend/src/main/java/com/festago/upload/domain/UploadFile.java @@ -0,0 +1,216 @@ +package com.festago.upload.domain; + +import com.festago.common.util.Validator; +import jakarta.annotation.Nonnull; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import java.net.URI; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.domain.Persistable; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.util.MimeType; + +@Slf4j +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class UploadFile implements Persistable { + + @Id + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") + private UploadStatus status; + + private long size; + + @Convert(converter = URIStringConverter.class) + private URI location; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") + private FileExtension extension; + + private Long ownerId; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") + private FileOwnerType ownerType; + + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + /** + * UploadFile을 생성한다.
UploadFile이 생성됐을 때, 파일은 어딘가에 업로드된 상태로 간주한다.
따라서 기본 상태는 UPLOADED 이다.
파일의 주인의 + * 생성 시점보다 파일이 더 먼저 생성될 수 있기에, ownerType, ownerId는 null이 될 수 있다.
+ * + * @param size 파일의 크기 + * @param location 파일이 저장된 URI + * @param extension 파일의 확장자 + * @param createdAt 파일이 저장된 시간 + */ + public UploadFile(long size, URI location, FileExtension extension, LocalDateTime createdAt) { + Validator.notNegative(size, "size"); + Validator.notNull(location, "location"); + Validator.notNull(extension, "extension"); + Validator.notNull(createdAt, "createdAt"); + this.id = UUID.randomUUID(); + this.status = UploadStatus.UPLOADED; + this.size = size; + this.location = location; + this.extension = extension; + this.ownerId = null; + this.ownerType = null; + this.createdAt = createdAt; + } + + /** + * UPLOADED 상태의 파일을 ASSIGNED 상태로 변경한다.
ASSIGNED 상태의 파일은 주인이 정해졌지만, 해당 주인이 파일을 소유하고 있지 않는 상태이다.
따라서 + * ASSIGNED 상태의 파일은 같은 주인이라도 여러 개가 생길 수 있다.
이후 파일을 다른 상태로 변경하려면 renewalStatus() 또는 changeAbandoned()를 호출해야 + * 한다.
+ * 파일의 주인은 정해졌지만, 해당 파일이 공유되어 사용될 수 있으므로 UPLOADED 상태의 파일만 ASSIGNED 상태로 변경할 수 있다.
+ * + * @param ownerId 파일 주인의 식별자 + * @param ownerType 파일 주인의 타입 + */ + public void changeAssigned(Long ownerId, FileOwnerType ownerType) { + Validator.notNull(ownerId, "ownerId"); + Validator.notNull(ownerType, "ownerType"); + if (status == UploadStatus.UPLOADED) { + this.status = UploadStatus.ASSIGNED; + this.ownerId = ownerId; + this.ownerType = ownerType; + } + } + + /** + * UPLOADED 상태의 파일을 ATTACHED 상태로 변경한다.
ATTACHED 상태의 파일은 주인이 해당 파일을 소유하고 있는 상태이다.
따라서 ATTACHED 상태의 파일은 + * 주인이 가진 파일 개수를 초과할 수 없다.
이후 파일을 다른 상태로 변경하려면 renewalStatus() 또는 changeAbandoned()를 호출해야 한다.
+ * 파일의 주인은 정해졌지만, 해당 파일이 공유되어 사용될 수 있으므로 UPLOADED 상태의 파일만 ATTACHED 상태로 변경할 수 있다.
+ * + * @param ownerId 파일 주인의 식별자 + * @param ownerType 파일 주인의 타입 + */ + public void changeAttached(Long ownerId, FileOwnerType ownerType) { + Validator.notNull(ownerId, "ownerId"); + Validator.notNull(ownerType, "ownerType"); + if (status == UploadStatus.UPLOADED) { + this.status = UploadStatus.ATTACHED; + this.ownerId = ownerId; + this.ownerType = ownerType; + } + } + + /** + * 현재 파일을 ABANDONED 상태로 변경한다.
ABANDONED 상태의 파일은 더 이상 주인이 소유하고 있지 않는 것을 의미한다.
따라서 해당 파일은 다시 다른 상태로 변경할 수 + * 없고, 삭제 대상이 된다.
+ */ + public void changeAbandoned() { + status = UploadStatus.ABANDONED; + } + + /** + * ASSIGNED 또는 ATTACHED 상태의 파일을 ATTACHED 또는 ABANDONED 상태로 변경한다.
하지만 사용자가 파일 등록을 여러번 시도하여 ASSIGNED 상태의 파일이 다수 + * 생성될 수 있다.
따라서 최종적으로 등록된 파일만 ATTACHED 상태로 변경하고 나머지는 ABANDONED 상태로 변경해야 한다.
그렇기에 최종적으로 등록되야할 파일의 식별자 + * 목록을 받은 뒤, 식별자 목록에 현재 파일의 식별자가 있고, ASSIGNED 또는 ATTACHED 상태의 파일을 PRE_ATTACHED로 변경한다.
그 뒤, PRE_ATTACHED 상태가 되지 + * 못한 파일은 사용자가 최종적으로 등록한 파일이 아니므로 ABANDONED 상태로 변경한다.
그리고 PRE_ATTACHED 상태의 파일은 ATTACHED 상태로 변경한다.
+ * 해당 파일이 공유되어 사용될 수 있으므로 ownerId과 ownerType이 동일한 파일만 상태를 변경할 수 있다.
+ * + * @param ownerId 파일 주인의 식별자 + * @param ownerType 파일 주인의 타입 + * @param ids 최종적으로 ATTACHED 상태를 가져야 할 파일의 식별자 목록 + */ + public void renewalStatus(Long ownerId, FileOwnerType ownerType, Set ids) { + if (isNotOwner(ownerId, ownerType)) { + return; + } + if (ids.contains(id) && isAssignedOrAttached()) { + status = UploadStatus.PRE_ATTACHED; + } + switch (status) { + case PRE_ATTACHED -> status = UploadStatus.ATTACHED; + case ASSIGNED, ATTACHED -> status = UploadStatus.ABANDONED; + default -> { + // NOOP + } + } + } + + private boolean isNotOwner(Long ownerId, FileOwnerType ownerType) { + return !Objects.equals(this.ownerId, ownerId) || this.ownerType != ownerType; + } + + private boolean isAssignedOrAttached() { + return status == UploadStatus.ASSIGNED || status == UploadStatus.ATTACHED; + } + + public MimeType getMimeType() { + return extension.getMimeType(); + } + + public URI getUploadUri() { + return location.resolve("/" + getName()); + } + + public String getName() { + return createdAt.toLocalDate() + "/" + id + extension.getValue(); + } + + @Nonnull + @Override + public UUID getId() { + return id; + } + + @Override + public boolean isNew() { + return updatedAt == null; + } + + public UploadStatus getStatus() { + return status; + } + + public long getSize() { + return size; + } + + public URI getLocation() { + return location; + } + + public FileExtension getExtension() { + return extension; + } + + public Long getOwnerId() { + return ownerId; + } + + public FileOwnerType getOwnerType() { + return ownerType; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/festago/upload/domain/UploadStatus.java b/backend/src/main/java/com/festago/upload/domain/UploadStatus.java new file mode 100644 index 000000000..9f4c2411d --- /dev/null +++ b/backend/src/main/java/com/festago/upload/domain/UploadStatus.java @@ -0,0 +1,10 @@ +package com.festago.upload.domain; + +public enum UploadStatus { + UPLOADED, + ASSIGNED, + PRE_ATTACHED, + ATTACHED, + ABANDONED, + ; +} diff --git a/backend/src/main/java/com/festago/upload/dto/FileUploadResult.java b/backend/src/main/java/com/festago/upload/dto/FileUploadResult.java new file mode 100644 index 000000000..8a6521a47 --- /dev/null +++ b/backend/src/main/java/com/festago/upload/dto/FileUploadResult.java @@ -0,0 +1,11 @@ +package com.festago.upload.dto; + +import java.net.URI; +import java.util.UUID; + +public record FileUploadResult( + UUID uploadFileId, + URI uploadUri +) { + +} diff --git a/backend/src/main/java/com/festago/upload/infrastructure/R2StorageClient.java b/backend/src/main/java/com/festago/upload/infrastructure/R2StorageClient.java new file mode 100644 index 000000000..e7f9b383b --- /dev/null +++ b/backend/src/main/java/com/festago/upload/infrastructure/R2StorageClient.java @@ -0,0 +1,119 @@ +package com.festago.upload.infrastructure; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.upload.domain.FileExtension; +import com.festago.upload.domain.StorageClient; +import com.festago.upload.domain.UploadFile; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +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.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Error; + +@Slf4j +@Component +public class R2StorageClient implements StorageClient { + + private final S3Client s3Client; + private final String bucket; + private final URI uri; + private final Clock clock; + + public R2StorageClient( + @Value("${festago.r2.access-key}") String accessKey, + @Value("${festago.r2.secret-key}") String secretKey, + @Value("${festago.r2.endpoint}") String endpoint, + @Value("${festago.r2.bucket}") String bucket, + @Value("${festago.r2.url}") String uri, + Clock clock + ) { + AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(accessKey, secretKey); + this.s3Client = S3Client.builder() + .credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)) + .endpointOverride(URI.create(endpoint)) + .region(Region.of("auto")) + .build(); + this.bucket = bucket; + this.uri = URI.create(uri); + this.clock = clock; + } + + @Override + public UploadFile storage(MultipartFile file) { + UploadFile uploadFile = createUploadFile(file); + upload(file, uploadFile); + return uploadFile; + } + + private UploadFile createUploadFile(MultipartFile file) { + return new UploadFile(file.getSize(), uri, FileExtension.from(file.getContentType()), LocalDateTime.now(clock)); + } + + private void upload(MultipartFile file, UploadFile uploadFile) { + PutObjectRequest objectRequest = PutObjectRequest.builder() + .key(uploadFile.getName()) + .bucket(bucket) + .build(); + try (InputStream inputStream = file.getInputStream()) { + long fileSize = uploadFile.getSize(); + String mimeType = uploadFile.getMimeType().toString(); + RequestBody requestBody = RequestBody.fromContentProvider(() -> inputStream, fileSize, mimeType); + UUID uploadFileId = uploadFile.getId(); + log.info("파일 업로드 시작. id={}, uploadUri={}, size={}", uploadFileId, uploadFile.getUploadUri(), fileSize); + s3Client.putObject(objectRequest, requestBody); + log.info("파일 업로드 완료. id={}", uploadFileId); + } catch (IOException e) { + log.warn("파일 업로드 중 문제가 발생했습니다. id={}", uploadFile.getId()); + throw new InternalServerException(ErrorCode.FILE_UPLOAD_ERROR, e); + } + } + + @Override + public void delete(List uploadFiles) { + if (uploadFiles.isEmpty()) { + log.info("삭제하려는 파일이 없습니다."); + return; + } + int fileSize = uploadFiles.size(); + UUID firstFileId = uploadFiles.get(0).getId(); + DeleteObjectsRequest deleteObjectsRequest = getDeleteObjectsRequest(uploadFiles); + + log.info("{}개 파일 삭제 시작. 첫 번째 파일 식별자={}", fileSize, firstFileId); + DeleteObjectsResponse response = s3Client.deleteObjects(deleteObjectsRequest); + log.info("{}개 파일 삭제 완료. 첫 번째 파일 식별자={}", fileSize, firstFileId); + + if (response.hasErrors()) { + List errors = response.errors(); + log.warn("{}개 파일 삭제 중 에러가 발생했습니다. 첫 번째 파일 식별자={}, 에러 개수={}", fileSize, firstFileId, errors.size()); + errors.forEach(error -> log.info("파일 삭제 중 에러가 발생했습니다. key={}, message={}", error.key(), error.message())); + } + } + + private DeleteObjectsRequest getDeleteObjectsRequest(List uploadFiles) { + List objectIdentifiers = uploadFiles.stream() + .map(UploadFile::getName) + .map(name -> ObjectIdentifier.builder().key(name).build()) + .toList(); + return DeleteObjectsRequest.builder() + .bucket(bucket) + .delete(builder -> builder.objects(objectIdentifiers).build()) + .build(); + } +} diff --git a/backend/src/main/java/com/festago/upload/presentation/FileOwnerTypeConverter.java b/backend/src/main/java/com/festago/upload/presentation/FileOwnerTypeConverter.java new file mode 100644 index 000000000..ea86c9d24 --- /dev/null +++ b/backend/src/main/java/com/festago/upload/presentation/FileOwnerTypeConverter.java @@ -0,0 +1,14 @@ +package com.festago.upload.presentation; + +import com.festago.upload.domain.FileOwnerType; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class FileOwnerTypeConverter implements Converter { + + @Override + public FileOwnerType convert(String fileOwnerType) { + return FileOwnerType.valueOf(fileOwnerType.toUpperCase()); + } +} diff --git a/backend/src/main/java/com/festago/upload/repository/UploadFileRepository.java b/backend/src/main/java/com/festago/upload/repository/UploadFileRepository.java new file mode 100644 index 000000000..b2eb159ab --- /dev/null +++ b/backend/src/main/java/com/festago/upload/repository/UploadFileRepository.java @@ -0,0 +1,34 @@ +package com.festago.upload.repository; + +import com.festago.upload.domain.FileOwnerType; +import com.festago.upload.domain.UploadFile; +import com.festago.upload.domain.UploadStatus; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +public interface UploadFileRepository extends Repository { + + UploadFile save(UploadFile uploadFile); + + Optional findById(UUID id); + + List findAllByOwnerIdAndOwnerType(Long ownerId, FileOwnerType ownerType); + + List findByIdIn(Collection ids); + + List findByCreatedAtBetweenAndStatus(LocalDateTime startTime, LocalDateTime endTime, + UploadStatus status); + + List findByCreatedAtBeforeAndStatus(LocalDateTime createdAt, UploadStatus status); + + @Modifying + @Query("delete from UploadFile uf where uf in :uploadFiles") + void deleteByIn(@Param("uploadFiles") List uploadFiles); +} diff --git a/backend/src/main/java/com/festago/upload/util/FileNameExtensionParser.java b/backend/src/main/java/com/festago/upload/util/FileNameExtensionParser.java new file mode 100644 index 000000000..f66cfdedd --- /dev/null +++ b/backend/src/main/java/com/festago/upload/util/FileNameExtensionParser.java @@ -0,0 +1,22 @@ +package com.festago.upload.util; + +public class FileNameExtensionParser { + + private FileNameExtensionParser() { + } + + /** + * 파일 이름의 확장자를 추출합니다.
확장자가 있으면 .png 와 같은 형식의 확장자를 반환합니다.
확장자가 없으면 빈 문자열을 반환합니다.
+ * + * @param filename null이 아닌 확장자를 추출할 파일 이름 + * @return .png, .jpg 와 같은 확장자, 만약 확장자가 없으면 빈 문자열 + */ + public static String parse(String filename) { + filename = filename.strip(); + int lastIndexOfDot = filename.lastIndexOf("."); + if (lastIndexOfDot == -1 || lastIndexOfDot == filename.length() - 1) { + return ""; + } + return filename.substring(lastIndexOfDot); + } +} diff --git a/backend/src/main/java/com/festago/upload/util/UriUploadFileIdParser.java b/backend/src/main/java/com/festago/upload/util/UriUploadFileIdParser.java new file mode 100644 index 000000000..2392c02c7 --- /dev/null +++ b/backend/src/main/java/com/festago/upload/util/UriUploadFileIdParser.java @@ -0,0 +1,40 @@ +package com.festago.upload.util; + +import java.net.URI; +import java.util.Optional; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; + +@Slf4j +public class UriUploadFileIdParser { + + private UriUploadFileIdParser() { + } + + /** + * 인자로 들어온 URI 형식의 문자열에 대해 UUID 형식의 파일 이름을 추출합니다.
해당 파일 이름은 UploadFile의 식별자로 사용됩니다.
만약 URI 형식이 아니거나, 파일 + * 이름이 UUID 형식이 아니면 빈 옵셔널을 반환합니다.
+ * + * @param uri UploadFile 식별자를 추출할 URI 문자열 + * @return 유효한 인자이면 UploadFile 식별자 값이 있는 옵셔널, 그 외 빈 옵셔널 반환 + */ + public static Optional parse(String uri) { + if (!StringUtils.hasText(uri)) { + return Optional.empty(); + } + try { + UUID uploadFileId = getUploadFileId(uri); + return Optional.of(uploadFileId); + } catch (Exception e) { + return Optional.empty(); + } + } + + private static UUID getUploadFileId(String uriString) { + URI uri = URI.create(uriString); + String path = uri.getPath(); + String fileName = path.substring(path.lastIndexOf('/') + 1, path.lastIndexOf('.')); + return UUID.fromString(fileName); + } +} diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml deleted file mode 100644 index ddf517560..000000000 --- a/backend/src/main/resources/application-local.yml +++ /dev/null @@ -1,32 +0,0 @@ -spring: - datasource: - url: jdbc:mysql://localhost:13306/festago - username: root - password: root - driver-class-name: com.mysql.cj.jdbc.Driver - jpa: - properties: - hibernate: - format_sql: true - show-sql: true - hibernate: - ddl-auto: validate - open-in-view: false - flyway: - enabled: true - baseline-on-migrate: true - baseline-version: 1 - -logging: - file: - path: ./ - level: - org: - hibernate: - orm: - jdbc: - bind: trace - -festago: - qr-secret-key: festagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestago - auth-secret-key: festagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestago diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 38c23dc60..d74c444c1 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,8 +1,3 @@ spring: profiles: active: local - config: - import: - - classpath:/festago-config/backend/application-dev.yml - - classpath:/festago-config/backend/application-prod.yml - - classpath:/festago-config/backend/application-infra.yml diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config new file mode 160000 index 000000000..9974ebc34 --- /dev/null +++ b/backend/src/main/resources/config @@ -0,0 +1 @@ +Subproject commit 9974ebc34f4ec43fd3163418a56d99001e825cfb diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 3622ca577..627fb92ac 100644 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -1,195 +1,254 @@ -create table if not exists festival +create table if not exists admin ( - id bigint not null auto_increment, - created_at datetime(6), - updated_at datetime(6), - end_date date, - name varchar(255), - start_date date, - thumbnail varchar(255), - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; + id bigint auto_increment primary key, + created_at datetime null, + updated_at datetime null, + username varchar(20) null, + password varchar(255) null, + constraint unique_username + unique (username) +); + +create table if not exists artist +( + id bigint auto_increment primary key, + name varchar(20) null, + profile_image_url varchar(512) null, + created_at datetime(6) null, + updated_at datetime(6) null, + background_image_url varchar(512) null +); + +create table if not exists bookmark +( + id bigint auto_increment primary key, + bookmark_type varchar(10) null, + resource_id bigint null, + member_id bigint null, + created_at datetime(6) null, + updated_at datetime(6) null, + constraint unique_bookmark_type_resource_id_member_id + unique (bookmark_type, resource_id, member_id) +); + +create index index_bookmark__member_id_bookmark_type + on bookmark (member_id, bookmark_type); + +create table if not exists festival_query_info +( + id bigint auto_increment primary key, + festival_id bigint not null, + created_at datetime(6) null, + updated_at datetime(6) null, + artist_info text not null, + constraint unique_festival_id + unique (festival_id) +); create table if not exists member ( - id bigint not null auto_increment, - created_at datetime(6), - updated_at datetime(6), - deleted_at datetime(6), - nickname varchar(255), - profile_image varchar(255), - social_id varchar(255), - social_type varchar(255), - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; + id bigint auto_increment primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + deleted_at datetime(6) null, + nickname varchar(20) null, + profile_image_url varchar(512) null, + social_id varchar(40) null, + social_type varchar(10) null, + constraint unique_social_id_social_type + unique (social_id, social_type) +); + +create table if not exists member_fcm +( + id bigint auto_increment primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + member_id bigint null, + fcm_token varchar(255) null, + constraint unique_member_id_fcm_token + unique (member_id, fcm_token), + constraint fk_member_fcm__member + foreign key (member_id) references member (id) +); -create table if not exists member_ticket +create table if not exists school ( - id bigint not null auto_increment, - created_at datetime(6), - updated_at datetime(6), - entry_state varchar(255), - entry_time datetime(6), - number integer not null, - ticket_type varchar(255), - owner_id bigint, - stage_id bigint, - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; + id bigint auto_increment primary key, + created_at datetime null, + updated_at datetime null, + domain varchar(25) not null, + name varchar(20) not null, + region varchar(20) not null, + logo_url varchar(512) null, + background_image_url varchar(512) null, + constraint unique_domain + unique (domain), + constraint unique_name + unique (name) +); + +create table if not exists festival +( + id bigint auto_increment primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + end_date date null, + name varchar(50) null, + start_date date null, + poster_image_url varchar(512) null, + school_id bigint not null, + constraint fk_festival__school + foreign key (school_id) references school (id) +); + +create index index_festival_end_date_desc + on festival (end_date desc); + +create index index_festival_start_date_desc + on festival (start_date desc); + +create index index_festival_start_date + on festival (start_date); + +create table if not exists social_media +( + id bigint auto_increment primary key, + owner_id bigint null, + owner_type varchar(10) null, + media_type varchar(12) null, + name varchar(50) null, + logo_url varchar(512) null, + url varchar(512) null, + created_at datetime(6) null, + updated_at datetime(6) null, + constraint unique_owner_id_owner_type_media_type + unique (owner_id, owner_type, media_type) +); create table if not exists stage ( - id bigint not null auto_increment, - created_at datetime(6), - updated_at datetime(6), - line_up varchar(255), + id bigint auto_increment primary key, + created_at datetime(6) null, + updated_at datetime(6) null, start_time datetime(6) not null, - ticket_open_time datetime(6), - festival_id bigint, - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; + ticket_open_time datetime(6) null, + festival_id bigint null, + constraint fk_stage__festival + foreign key (festival_id) references festival (id) +); -create table if not exists ticket +create table if not exists member_ticket ( - id bigint not null auto_increment, - created_at datetime(6), - updated_at datetime(6), - ticket_type varchar(255), - stage_id bigint, - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; - -create table if not exists ticket_amount + id bigint auto_increment primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + entry_state varchar(15) null, + entry_time datetime(6) null, + number int not null, + ticket_type varchar(10) null, + owner_id bigint null, + stage_id bigint null, + constraint fk_member_ticket__member + foreign key (owner_id) references member (id), + constraint fk_member_ticket__stage + foreign key (stage_id) references stage (id) +); + +create table if not exists stage_artist +( + id bigint auto_increment primary key, + stage_id bigint not null, + artist_id bigint not null, + created_at datetime(6) null, + updated_at datetime(6) null, + constraint fk_stage_artist__artist + foreign key (artist_id) references artist (id), + constraint fk_stage_artist__stage + foreign key (stage_id) references stage (id) +); + +create table if not exists stage_query_info ( - ticket_id bigint not null, - created_at datetime(6), - updated_at datetime(6), - reserved_amount integer not null, - total_amount integer not null, - primary key (ticket_id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; + id bigint auto_increment primary key, + stage_id bigint not null, + created_at datetime(6) null, + updated_at datetime(6) null, + artist_info text not null, + constraint unique_stage_id + unique (stage_id) +); -create table if not exists ticket_entry_time +create table if not exists student ( - id bigint not null auto_increment, - created_at datetime(6), - updated_at datetime(6), - amount integer, - entry_time datetime(6), - ticket_id bigint, - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; + id bigint auto_increment primary key, + created_at datetime null, + updated_at datetime null, + username varchar(50) not null, + member_id bigint not null, + school_id bigint not null, + constraint fk_student__member + foreign key (member_id) references member (id), + constraint fk_student__school + foreign key (school_id) references school (id) +); -create table if not exists school +create table if not exists student_code ( - id bigint not null auto_increment, - created_at datetime null, - updated_at datetime null, - domain varchar(50) not null, - name varchar(255) not null, - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; + id bigint auto_increment primary key, + school_id bigint null, + member_id bigint null, + code varchar(50) null, + username varchar(50) null, + issued_at datetime(6) not null, + constraint unique_member_id + unique (member_id), + constraint fk_student_code__member + foreign key (member_id) references member (id), + constraint fk_student_code__school + foreign key (school_id) references school (id) +); -create table if not exists student +create table if not exists ticket ( - id bigint not null auto_increment, - created_at datetime null, - updated_at datetime null, - username varchar(255) not null, - member_id bigint not null, - school_id bigint not null, - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; + id bigint auto_increment primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + ticket_type varchar(10) null, + stage_id bigint null, + school_id bigint not null, + constraint fk_ticket__school + foreign key (school_id) references school (id), + constraint fk_ticket__stage + foreign key (stage_id) references stage (id) +); -create table if not exists student_code +create table if not exists ticket_amount ( - id bigint auto_increment not null, - created_at datetime null, - updated_at datetime null, - school_id bigint null, - member_id bigint null, - code varchar(255) null, - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; + ticket_id bigint not null primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + reserved_amount int not null, + total_amount int not null, + constraint fk_ticket_amount__ticket + foreign key (ticket_id) references ticket (id) +); -create table if not exists admin +create table if not exists ticket_entry_time +( + id bigint auto_increment primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + amount int null, + entry_time datetime(6) null, + ticket_id bigint null, + constraint fk_ticket_entry_time__ticket + foreign key (ticket_id) references ticket (id) +); + +create table if not exists refresh_token ( - id bigint auto_increment not null, - created_at datetime null, - updated_at datetime null, - username varchar(255) null, - password varchar(255) null, - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; - -alter table member_ticket - add constraint fk_member_ticket__member - foreign key (owner_id) - references member (id); - -alter table member_ticket - add constraint fk_member_ticket__stage - foreign key (stage_id) - references stage (id); - -alter table stage - add constraint fk_stage__festival - foreign key (festival_id) - references festival (id); - -alter table ticket - add constraint fk_ticket__stage - foreign key (stage_id) - references stage (id); - -alter table ticket_amount - add constraint fk_ticket_amount__ticket - foreign key (ticket_id) - references ticket (id); - -alter table ticket_entry_time - add constraint fk_ticket_entry_time__ticket - foreign key (ticket_id) - references ticket (id); - -alter table student - add constraint fk_student__member - foreign key (member_id) - references member (id); - -alter table student - add constraint fk_student__school - foreign key (school_id) - references school (id); - -alter table student_code - add constraint fk_student_code__member - foreign key (member_id) - references member (id); - -alter table student_code - add constraint fk_student_code__school - foreign key (school_id) - references school (id); + id binary(16) primary key, + member_id bigint not null, + expired_at datetime(6) not null +); diff --git a/backend/src/main/resources/db/migration/V2__add_refresh_token_auditing.sql b/backend/src/main/resources/db/migration/V2__add_refresh_token_auditing.sql new file mode 100644 index 000000000..a02763c18 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_refresh_token_auditing.sql @@ -0,0 +1,6 @@ +alter table refresh_token + add created_at datetime(6) null; +alter table refresh_token + add updated_at datetime(6) null; + + diff --git a/backend/src/main/resources/db/migration/V2__student_code_add_username.sql b/backend/src/main/resources/db/migration/V2__student_code_add_username.sql deleted file mode 100644 index 8873d57cd..000000000 --- a/backend/src/main/resources/db/migration/V2__student_code_add_username.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table student_code - add column username varchar(255); diff --git a/backend/src/main/resources/db/migration/V3__add_upload_file.sql b/backend/src/main/resources/db/migration/V3__add_upload_file.sql new file mode 100644 index 000000000..08ea2c02f --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__add_upload_file.sql @@ -0,0 +1,15 @@ +CREATE TABLE upload_file +( + id binary(16) not null primary key, + created_at datetime(6) not null, + updated_at datetime(6) not null, + size bigint not null, + location varchar(255) not null, + status varchar(15) not null, + extension varchar(6) not null, + owner_type varchar(12) null, + owner_id bigint null +); + +create index index_upload_file__owner_id_owner_type + on upload_file (owner_id, owner_type); diff --git a/backend/src/main/resources/db/migration/V3__unique_admin_social.sql b/backend/src/main/resources/db/migration/V3__unique_admin_social.sql deleted file mode 100644 index 2b47d2cd6..000000000 --- a/backend/src/main/resources/db/migration/V3__unique_admin_social.sql +++ /dev/null @@ -1,5 +0,0 @@ -alter table admin - add constraint UNIQUE_USERNAME unique (username); - -alter table member - add constraint UNIQUE_SOCIAL unique (social_id, social_type); diff --git a/backend/src/main/resources/db/migration/V4__add_school_fk.sql b/backend/src/main/resources/db/migration/V4__add_school_fk.sql deleted file mode 100644 index bcdb2f62a..000000000 --- a/backend/src/main/resources/db/migration/V4__add_school_fk.sql +++ /dev/null @@ -1,15 +0,0 @@ -alter table ticket - add column school_id bigint; - -alter table ticket - add constraint fk_ticket__school - foreign key (school_id) - references school (id); - -alter table festival - add column school_id bigint; - -alter table festival - add constraint fk_festival__school - foreign key (school_id) - references school (id); diff --git a/backend/src/main/resources/db/migration/V4__remove_school_unique_domain.sql b/backend/src/main/resources/db/migration/V4__remove_school_unique_domain.sql new file mode 100644 index 000000000..a07518710 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__remove_school_unique_domain.sql @@ -0,0 +1,2 @@ +alter table school + drop key unique_domain; diff --git a/backend/src/main/resources/db/migration/V5__MembeFCM_Added.sql b/backend/src/main/resources/db/migration/V5__MembeFCM_Added.sql deleted file mode 100644 index f71564c9d..000000000 --- a/backend/src/main/resources/db/migration/V5__MembeFCM_Added.sql +++ /dev/null @@ -1,16 +0,0 @@ -create table if not exists member_fcm -( - id bigint not null auto_increment, - created_at datetime(6), - updated_at datetime(6), - member_id bigint, - fcm_token varchar(255), - primary key (id) -) engine innodb - default charset = utf8mb4 - collate = utf8mb4_0900_ai_ci; - -alter table member_fcm - add constraint fk_member_fcm__member - foreign key (member_id) - references member (id); diff --git a/backend/src/main/resources/db/migration/V5__modify_member_social_id.sql b/backend/src/main/resources/db/migration/V5__modify_member_social_id.sql new file mode 100644 index 000000000..0e0091e25 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__modify_member_social_id.sql @@ -0,0 +1,2 @@ +alter table member + modify social_id varchar(45) null; diff --git a/backend/src/main/resources/db/migration/V6__school_notnull.sql b/backend/src/main/resources/db/migration/V6__school_notnull.sql deleted file mode 100644 index 51735574f..000000000 --- a/backend/src/main/resources/db/migration/V6__school_notnull.sql +++ /dev/null @@ -1,5 +0,0 @@ -alter table festival - modify school_id bigint not null; - -alter table ticket - modify school_id bigint not null; diff --git a/backend/src/main/resources/db/migration/V7__studnet_code__unique.sql b/backend/src/main/resources/db/migration/V7__studnet_code__unique.sql deleted file mode 100644 index ef58d4a62..000000000 --- a/backend/src/main/resources/db/migration/V7__studnet_code__unique.sql +++ /dev/null @@ -1,18 +0,0 @@ --- issued_at 칼럼 추가 (NOT NULL) -alter table student_code - add column issued_at datetime(6) not null - default '1999-12-31 00:00:00'; - -alter table student_code - alter column issued_at drop default; - --- 기존 created_at updated_at 삭제 -alter table student_code - drop column created_at; - -alter table student_code - drop column updated_at; - --- StudentCode의 member_id UNIQUE 제약조건 추가 -alter table student_code - modify column member_id bigint unique; diff --git a/backend/src/main/resources/db/migration/V8__unique_member_fcm.sql b/backend/src/main/resources/db/migration/V8__unique_member_fcm.sql deleted file mode 100644 index 52f3684cd..000000000 --- a/backend/src/main/resources/db/migration/V8__unique_member_fcm.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table member_fcm - add constraint unique_member_fcm unique (member_id, fcm_token); diff --git a/backend/src/main/resources/festago-config b/backend/src/main/resources/festago-config deleted file mode 160000 index 3214a7b3a..000000000 --- a/backend/src/main/resources/festago-config +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3214a7b3a466f5878fd957c130f47e94f36e5893 diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml index 1e0713bc2..8454df8e3 100644 --- a/backend/src/main/resources/logback-spring.xml +++ b/backend/src/main/resources/logback-spring.xml @@ -20,20 +20,17 @@ - - - @@ -44,22 +41,17 @@ - - - - - diff --git a/backend/src/main/resources/logs/slack/slack-error-appender.xml b/backend/src/main/resources/logs/slack/slack-error-appender.xml deleted file mode 100644 index 3a423d95c..000000000 --- a/backend/src/main/resources/logs/slack/slack-error-appender.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - ${WEBHOOK_URL} - - ${FILE_LOG_PATTERN} - - true - - - - - - ERROR - ACCEPT - DENY - - - diff --git a/backend/src/main/resources/logs/slack/slack-warn-appender.xml b/backend/src/main/resources/logs/slack/slack-warn-appender.xml deleted file mode 100644 index f2ad7079b..000000000 --- a/backend/src/main/resources/logs/slack/slack-warn-appender.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - ${WEBHOOK_URL} - - ${FILE_LOG_PATTERN} - - true - - - - - - WARN - ACCEPT - DENY - - - diff --git a/backend/src/main/resources/static/css/404.css b/backend/src/main/resources/static/css/404.css deleted file mode 100644 index cddded461..000000000 --- a/backend/src/main/resources/static/css/404.css +++ /dev/null @@ -1,33 +0,0 @@ -body { - font-family: Arial, sans-serif; - background-color: #f4f4f4; - margin: 0; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - height: 100vh; -} - -.container { - width: 100%; - max-width: 600px; - background-color: #ffffff; - box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.1); -} - -.content { - text-align: center; - padding: 50px; -} - -h3 { - font-size: 40px; - color: #37569a; - margin-bottom: 10px; -} - -p { - color: #666; - font-size: 20px; -} diff --git a/backend/src/main/resources/static/css/admin/admin-page.css b/backend/src/main/resources/static/css/admin/admin-page.css deleted file mode 100644 index e10695319..000000000 --- a/backend/src/main/resources/static/css/admin/admin-page.css +++ /dev/null @@ -1,104 +0,0 @@ -body { - font-family: Arial, sans-serif; - margin: 0; - padding: 0; - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; /* Center align content vertically */ - background-color: #f2f2f2; -} - -.container { - display: flex; - flex-wrap: wrap; /* Wrap the form sections when they reach the container width */ - justify-content: flex-start; /* Align items to the left */ - width: 80%; - margin-top: 50px; - padding-top: 20px; /* Add some top padding to the container */ - min-height: 500px; /* Set a minimum height for the container to accommodate the data */ -} - -.form-section { - flex: 1; /* Make all three request sections have the same width */ - border: 1px solid #ddd; - padding: 20px; - border-radius: 8px; - margin: 10px; /* Add some margin around each form section */ - background-color: #fff; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - min-width: 230px; /* Set a minimum width for each form section to prevent them from becoming too narrow */ -} - -h2 { - margin-top: 0; - color: #444; -} - -form { - padding: 20px; -} - -label { - display: block; - margin-bottom: 5px; - color: #444; -} - -input, -select { - width: 100%; - padding: 8px; - margin-bottom: 10px; - border: 1px solid #ddd; - border-radius: 4px; -} - -button { - background-color: #4CAF50; - color: white; - padding: 10px 20px; - border: none; - cursor: pointer; - width: 100%; - border-radius: 4px; -} - -button:hover { - background-color: #45a049; -} - -/* Add some spacing to the data display area */ -#dataSection { - margin-top: 50px; - width: 100%; /* Make sure the data section spans the full width */ - display: flex; - flex-direction: column; /* Display the data divs vertically */ - align-items: flex-start; /* Left-align the data divs horizontally */ -} - -/* Style for the data divs */ -.dataDiv { - margin: 10px; - border: 1px solid #ddd; - border-radius: 8px; - background-color: #fff; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - width: 100%; /* Make sure the data div spans the full width */ -} - -/* Style for the data table */ -table { - border-collapse: collapse; - width: 100%; /* Make the table span the full width */ -} - -th, td { - padding: 12px; - text-align: left; - border-bottom: 1px solid #ddd; -} - -th { - background-color: #f2f2f2; -} diff --git a/backend/src/main/resources/static/css/admin/login.css b/backend/src/main/resources/static/css/admin/login.css deleted file mode 100644 index e16f879e7..000000000 --- a/backend/src/main/resources/static/css/admin/login.css +++ /dev/null @@ -1,43 +0,0 @@ -body { - font-family: Arial, sans-serif; - background-color: #f4f4f4; - margin: 0; - padding: 0; -} - -.login-container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 300px; - padding: 20px; - background-color: #ffffff; - box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.1); -} - -label, input { - width: 100%; - display: block; - margin: 5px 0; -} - -input[type="text"], -input[type="password"] { - width: 100%; - box-sizing: border-box; - padding: 10px; -} - -button { - padding: 10px 15px; - background-color: #007BFF; - color: #ffffff; - border: none; - cursor: pointer; - width: 100%; -} - -button:hover { - background-color: #0056b3; -} diff --git a/backend/src/main/resources/static/error/404.html b/backend/src/main/resources/static/error/404.html deleted file mode 100644 index 3b75ba2eb..000000000 --- a/backend/src/main/resources/static/error/404.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - 404 - Not Found - - - -
-
-

해당 페이지가 없습니다. 😭

-

URL이 올바른지 다시 확인해주세요!

-
-
- - diff --git a/backend/src/main/resources/static/images/default-profile.png b/backend/src/main/resources/static/images/default-profile.png deleted file mode 100644 index 7127aa862..000000000 Binary files a/backend/src/main/resources/static/images/default-profile.png and /dev/null differ diff --git a/backend/src/main/resources/static/js/admin/admin-page.js b/backend/src/main/resources/static/js/admin/admin-page.js deleted file mode 100644 index 4de514e4e..000000000 --- a/backend/src/main/resources/static/js/admin/admin-page.js +++ /dev/null @@ -1,66 +0,0 @@ -function init() { - const serverVersionBtn = document.getElementById("serverVersionBtn"); - const infoLogBtn = document.getElementById("infoLogBtn"); - const warnLogBtn = document.getElementById("warnLogBtn"); - const errorLogBtn = document.getElementById("errorLogBtn"); - - serverVersionBtn.addEventListener("click", showServerVersion); - infoLogBtn.addEventListener("click", executeInfoLog); - warnLogBtn.addEventListener("click", executeWarnLog); - errorLogBtn.addEventListener("click", executeErrorLog); -} - -function showServerVersion() { - fetch("/admin/api/version") - .then(res => { - if (res.ok) { - return res.text(); - } - throw new Error("서버에 연결할 수 없습니다."); - }).then(body => { - alert(body); - }).catch(error => { - alert(error.message); - }) -} - -function executeInfoLog() { - fetch("/admin/api/info") - .then(res => { - if (res.status !== 400) { - throw new Error("서버에 연결할 수 없습니다."); - } - }).then(() => { - alert("실행 완료"); - }).catch(error => { - alert(error.message); - }) -} - -function executeWarnLog() { - fetch("/admin/api/warn") - .then(res => { - if (res.status !== 500) { - throw new Error("서버에 연결할 수 없습니다."); - } - }).then(() => { - alert("실행 완료"); - }).catch(error => { - alert(error.message); - }) -} - -function executeErrorLog() { - fetch("/admin/api/error") - .then(res => { - if (res.status !== 500) { - throw new Error("서버에 연결할 수 없습니다."); - } - }).then(() => { - alert("실행 완료"); - }).catch(error => { - alert(error.message); - }) -} - -init(); diff --git a/backend/src/main/resources/static/js/admin/festival/common-festival.js b/backend/src/main/resources/static/js/admin/festival/common-festival.js deleted file mode 100644 index dd55774cc..000000000 --- a/backend/src/main/resources/static/js/admin/festival/common-festival.js +++ /dev/null @@ -1,14 +0,0 @@ -export function validateFestival(festivalData) { - const startDate = new Date(festivalData.startDate); - const endDate = new Date(festivalData.endDate); - let hasError = false; - if (startDate > endDate) { - document.getElementById("festivalEndDate").classList.add("is-invalid"); - document.getElementById("festivalEndDate-feedback") - .textContent = "종료일은 시작일보다 이후 이어야 합니다." - hasError = true; - } - if (hasError) { - throw new Error("검증이 실패하였습니다."); - } -} diff --git a/backend/src/main/resources/static/js/admin/festival/manage-festival-detail.js b/backend/src/main/resources/static/js/admin/festival/manage-festival-detail.js deleted file mode 100644 index 39dd65855..000000000 --- a/backend/src/main/resources/static/js/admin/festival/manage-festival-detail.js +++ /dev/null @@ -1,234 +0,0 @@ -import {validateFestival} from "./common-festival.js" -import {getResourceId} from "../../common/UrlParser.js"; - -const deleteConfirmModal = new bootstrap.Modal( - document.getElementById("deleteConfirmModal")); - -function fetchFestival() { - const idInput = document.getElementById("id"); - const fakeIdInput = document.getElementById("fakeId"); - const schoolIdInput = document.getElementById("schoolId"); - const fakeSchoolIdInput = document.getElementById("fakeSchoolId"); - const nameInput = document.getElementById("name"); - const thumbnailInput = document.getElementById("thumbnail"); - const startDateInput = document.getElementById("festivalStartDate"); - const endDateInput = document.getElementById("festivalEndDate"); - const updateBtn = document.getElementById("festivalUpdateBtn"); - const deleteBtn = document.getElementById("festivalDeleteBtn"); - const festivalId = getResourceId(new URL(window.location.href)); - const errorModal = new bootstrap.Modal(document.getElementById("errorModal")); - - fetch(`/festivals/${festivalId}`).then(res => { - if (!res.ok) { - nameInput.setAttribute("disabled", ""); - thumbnailInput.setAttribute("disabled", ""); - startDateInput.setAttribute("disabled", ""); - endDateInput.setAttribute("disabled", ""); - updateBtn.setAttribute("disabled", ""); - deleteBtn.setAttribute("disabled", ""); - return res.json().then(data => { - throw new Error(data.message || data.detail) - }) - } - return res.json(); - }).then(festival => { - idInput.value = festival.id; - fakeIdInput.value = festival.id; - schoolIdInput.value = festival.schoolId - fakeSchoolIdInput.value = festival.schoolId - nameInput.value = festival.name; - thumbnailInput.value = festival.thumbnail; - startDateInput.value = festival.startDate; - endDateInput.value = festival.endDate; - renderStages(festival.stages) - }).catch(error => { - const errorModalBody = document.getElementById("errorModalBody"); - errorModalBody.textContent = error.message; - errorModal.show(); - }) -} - -fetchFestival(); - -function init() { - const festivalUpdateForm = document.getElementById("festivalUpdateForm"); - const deleteBtn = document.getElementById("festivalDeleteBtn"); - const actualDeleteBtn = document.getElementById("actualDeleteBtn"); - const stageCreateFrom = document.getElementById("stageCreateForm"); - - festivalUpdateForm.addEventListener("submit", updateFestival); - deleteBtn.addEventListener("click", openDeleteConfirmModal); - actualDeleteBtn.addEventListener("click", deleteFestival); - stageCreateFrom.addEventListener("submit", createStage); -} - -function updateFestival(e) { - e.preventDefault(); - const formData = new FormData(e.target); - const festivalData = { - name: formData.get("name"), - startDate: formData.get("festivalStartDate"), - endDate: formData.get("festivalEndDate"), - thumbnail: formData.get("thumbnail"), - }; - validateFestival(festivalData) - const festivalId = formData.get("id"); - fetch(`/admin/api/festivals/${festivalId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(festivalData) - }) - .then(response => { - if (response.ok) { - return response; - } else { - return response.json().then(data => { - throw new Error(data.message || "축제 수정에 실패하였습니다."); - }); - } - }) - .then(() => { - alert("축제가 성공적으로 수정되었습니다!"); - location.reload(); - }) - .catch(error => { - alert(error.message); - }); -} - -init(); - -function openDeleteConfirmModal() { - deleteConfirmModal.show(); -} - -function deleteFestival() { - const idInput = document.getElementById("id"); - const festivalId = idInput.value; - fetch(`/admin/api/festivals/${festivalId}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json" - } - }) - .then(response => { - if (response.ok) { - return response; - } else { - return response.json().then(data => { - throw new Error(data.message || "축제 삭제에 실패하였습니다."); - }); - } - }) - .then(() => { - alert("축제가 성공적으로 삭제되었습니다!"); - location.replace("/admin/festivals"); - }) - .catch(error => { - deleteConfirmModal.hide(); - alert(error.message); - }); -} - -function renderStages(stages) { - const stageGrid = document.getElementById("stageGrid"); - for (const stage of stages) { - const row = document.createElement("div"); - row.classList.add("row", "align-items-center", "gx-0", "py-1", - "border-top"); - - const idColumn = document.createElement("div"); - idColumn.classList.add("col-1"); - idColumn.textContent = stage.id; - - const startTimeColumn = document.createElement("div"); - startTimeColumn.classList.add("col-3"); - startTimeColumn.textContent = stage.startTime; - - const ticketOpenTimeColumn = document.createElement("div"); - ticketOpenTimeColumn.classList.add("col-3"); - ticketOpenTimeColumn.textContent = stage.ticketOpenTime; - - const lineUpColumn = document.createElement("div"); - lineUpColumn.classList.add("col-3"); - lineUpColumn.textContent = stage.lineUp; - - const buttonColumn = document.createElement("div"); - buttonColumn.classList.add("col-2") - const button = document.createElement("a"); - button.classList.add("btn", "btn-primary"); - button.setAttribute("href", `/admin/stages/${stage.id}`); - button.textContent = "편집"; - buttonColumn.append(button); - - row.append(idColumn, startTimeColumn, ticketOpenTimeColumn, lineUpColumn, - buttonColumn); - stageGrid.append(row); - } -} - -function createStage(e) { - e.preventDefault(); - const formData = new FormData(e.target); - const stageData = { - festivalId: document.getElementById("id").value, - startTime: formData.get("stageStartTime"), - ticketOpenTime: formData.get("ticketOpenTime"), - lineUp: formData.get("lineUp"), - }; - validateStage(stageData); - - fetch("/admin/api/stages", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(stageData) - }) - .then(response => { - if (response.ok) { - return response.json(); - } else { - return response.json().then(data => { - throw new Error(data.message || "공연 생성에 실패하였습니다."); - }); - } - }) - .then(data => { - alert("공연이 성공적으로 생성되었습니다!"); - location.reload(); - }) - .catch(error => { - alert(error.message); - }); -} - -function validateStage(stageData) { - const stageStartTime = new Date(stageData.startTime); - const ticketOpenTime = new Date(stageData.ticketOpenTime); - const now = new Date(); - let hasError = false; - if (stageStartTime <= ticketOpenTime) { - document.getElementById("ticketOpenTime").classList.add("is-invalid"); - document.getElementById("ticketOpenTime-feedback") - .textContent = "티켓 오픈 시간은 공연 시작 이전 이어야 합니다." - hasError = true; - } - if (stageStartTime < now) { - document.getElementById("stageStartTime").classList.add("is-invalid"); - document.getElementById("stageStartTime-feedback") - .textContent = "공연 시작 시간은 현재보다 이후 이어야 합니다." - hasError = true; - } - if (ticketOpenTime < now) { - document.getElementById("ticketOpenTime").classList.add("is-invalid"); - document.getElementById("ticketOpenTime-feedback") - .textContent = "티켓 오픈 시간은 현재보다 이후 이어야 합니다." - hasError = true; - } - if (hasError) { - throw new Error("검증이 실패하였습니다."); - } -} diff --git a/backend/src/main/resources/static/js/admin/festival/manage-festival.js b/backend/src/main/resources/static/js/admin/festival/manage-festival.js deleted file mode 100644 index 03e271afb..000000000 --- a/backend/src/main/resources/static/js/admin/festival/manage-festival.js +++ /dev/null @@ -1,123 +0,0 @@ -import {validateFestival} from "./common-festival.js" - -function fetchFestivals() { - const festivalGrid = document.getElementById("festivalGrid"); - - fetch("/festivals").then(res => { - if (!res.ok) { - throw new Error("서버에 연결할 수 없습니다.") - } - return res.json(); - }).then(data => { - const festivals = data.festivals; - for (const festival of festivals) { - const row = document.createElement("div"); - row.classList.add("row", "align-items-center", "gx-0", "py-1", - "border-top"); - - const idColumn = document.createElement("div"); - idColumn.classList.add("col-1"); - idColumn.textContent = festival.id; - - const schoolIdColumn = document.createElement("div"); - schoolIdColumn.classList.add("col-1"); - schoolIdColumn.textContent = festival.schoolId; - - const nameColumn = document.createElement("div"); - nameColumn.classList.add("col-2"); - nameColumn.textContent = festival.name; - - const thumbnailColumn = document.createElement("div"); - thumbnailColumn.classList.add("col-2"); - thumbnailColumn.textContent = festival.thumbnail; - - const startDateColumn = document.createElement("div"); - startDateColumn.classList.add("col-2"); - startDateColumn.textContent = festival.startDate; - - const endDateColumn = document.createElement("div"); - endDateColumn.classList.add("col-2"); - endDateColumn.textContent = festival.endDate; - - const buttonColumn = document.createElement("div"); - buttonColumn.classList.add("col-2") - const button = document.createElement("a"); - button.classList.add("btn", "btn-primary"); - button.setAttribute("href", `festivals/${festival.id}`); - button.textContent = "편집"; - buttonColumn.append(button); - - row.append(idColumn, schoolIdColumn, nameColumn, thumbnailColumn, - startDateColumn, endDateColumn, buttonColumn); - festivalGrid.append(row); - } - }) -} - -fetchFestivals(); - -function fetchSchools() { - const schoolSelect = document.getElementById("schoolSelect"); - - fetch("/schools").then(res => { - if (!res.ok) { - throw new Error("서버에 연결할 수 없습니다.") - } - return res.json(); - }).then(data => { - const schools = data.schools; - for (const school of schools) { - const option = document.createElement("option"); - const schoolId = school.id; - option.textContent = `${school.name} (ID=${schoolId})`; - option.value = schoolId; - schoolSelect.append(option); - } - }) -} - -fetchSchools(); - -function init() { - const festivalCreateForm = document.getElementById("festivalCreateForm"); - festivalCreateForm.addEventListener("submit", createFestival); -} - -function createFestival(e) { - e.preventDefault(); - const formData = new FormData(e.target); - const festivalData = { - name: formData.get("name"), - startDate: formData.get("festivalStartDate"), - endDate: formData.get("festivalEndDate"), - thumbnail: formData.get("thumbnail"), - schoolId: formData.get("school"), - }; - validateFestival(festivalData) - - fetch("/admin/api/festivals", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(festivalData) - }) - .then(response => { - if (response.ok) { - return response.json(); - } else { - return response.json().then(data => { - throw new Error(data.message || "축제 생성에 실패하였습니다."); - }); - } - }) - .then(data => { - alert("축제가 성공적으로 생성되었습니다!"); - location.reload(); - }) - .catch(error => { - alert(error.message); - }); -} - -init(); diff --git a/backend/src/main/resources/static/js/admin/school/manage-school-detail.js b/backend/src/main/resources/static/js/admin/school/manage-school-detail.js deleted file mode 100644 index a0027da54..000000000 --- a/backend/src/main/resources/static/js/admin/school/manage-school-detail.js +++ /dev/null @@ -1,116 +0,0 @@ -import {getResourceId} from "../../common/UrlParser.js"; - -const deleteConfirmModal = new bootstrap.Modal( - document.getElementById("deleteConfirmModal")); - -function fetchSchool() { - const idInput = document.getElementById("id"); - const fakeIdInput = document.getElementById("fakeId"); - const nameInput = document.getElementById("name"); - const domainInput = document.getElementById("domain"); - const updateBtn = document.getElementById("updateBtn"); - const deleteBtn = document.getElementById("deleteBtn"); - const schoolId = getResourceId(new URL(window.location.href)); - const errorModal = new bootstrap.Modal(document.getElementById("errorModal")); - - fetch(`/schools/${schoolId}`).then(res => { - if (!res.ok) { - nameInput.setAttribute("disabled", ""); - domainInput.setAttribute("disabled", ""); - updateBtn.setAttribute("disabled", ""); - deleteBtn.setAttribute("disabled", ""); - return res.json().then(data => { - throw new Error(data.message || data.detail) - }) - } - return res.json(); - }).then(school => { - idInput.value = school.id; - fakeIdInput.value = school.id; - nameInput.value = school.name; - domainInput.value = school.domain; - }).catch(error => { - const errorModalBody = document.getElementById("errorModalBody"); - errorModalBody.textContent = error.message; - errorModal.show(); - }) -} - -fetchSchool(); - -function init() { - const schoolUpdateForm = document.getElementById("schoolUpdateForm"); - const deleteBtn = document.getElementById("deleteBtn"); - const actualDeleteBtn = document.getElementById("actualDeleteBtn"); - - schoolUpdateForm.addEventListener("submit", updateSchool); - deleteBtn.addEventListener("click", openDeleteConfirmModal); - actualDeleteBtn.addEventListener("click", deleteSchool) -} - -function updateSchool(e) { - e.preventDefault(); - const formData = new FormData(e.target); - const schoolData = { - name: formData.get("name"), - domain: formData.get("domain"), - }; - const schoolId = formData.get("id"); - fetch(`/admin/api/schools/${schoolId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(schoolData) - }) - .then(response => { - if (response.ok) { - return response; - } else { - return response.json().then(data => { - throw new Error(data.message || "학교 수정에 실패하였습니다."); - }); - } - }) - .then(() => { - alert("학교가 성공적으로 수정되었습니다!"); - location.reload(); - }) - .catch(error => { - alert(error.message); - }); -} - -init(); - -function openDeleteConfirmModal() { - deleteConfirmModal.show(); -} - -function deleteSchool() { - const idInput = document.getElementById("id"); - const schoolId = idInput.value; - fetch(`/admin/api/schools/${schoolId}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json" - } - }) - .then(response => { - if (response.ok) { - return response; - } else { - return response.json().then(data => { - throw new Error(data.message || "학교 삭제에 실패하였습니다."); - }); - } - }) - .then(() => { - alert("학교가 성공적으로 삭제되었습니다!"); - location.replace("/admin/schools"); - }) - .catch(error => { - deleteConfirmModal.hide(); - alert(error.message); - }); -} diff --git a/backend/src/main/resources/static/js/admin/school/manage-school.js b/backend/src/main/resources/static/js/admin/school/manage-school.js deleted file mode 100644 index be0cd897f..000000000 --- a/backend/src/main/resources/static/js/admin/school/manage-school.js +++ /dev/null @@ -1,82 +0,0 @@ -function fetchSchools() { - const schoolGrid = document.getElementById("schoolGrid"); - - fetch("/schools").then(res => { - if (!res.ok) { - throw new Error("서버에 연결할 수 없습니다.") - } - return res.json(); - }).then(data => { - const schools = data.schools; - for (const school of schools) { - const row = document.createElement("div"); - row.classList.add("row", "align-items-center", "gx-0", "py-1", - "border-top"); - - const idColumn = document.createElement("div"); - idColumn.classList.add("col-2"); - idColumn.textContent = school.id; - - const nameColumn = document.createElement("div"); - nameColumn.classList.add("col-4"); - nameColumn.textContent = school.name; - - const domainColumn = document.createElement("div"); - domainColumn.classList.add("col-4"); - domainColumn.textContent = school.domain; - - const buttonColumn = document.createElement("div"); - buttonColumn.classList.add("col-2") - const button = document.createElement("a"); - button.classList.add("btn", "btn-primary"); - button.setAttribute("href", `schools/${school.id}`); - button.textContent = "편집"; - buttonColumn.append(button); - - row.append(idColumn, nameColumn, domainColumn, buttonColumn); - schoolGrid.append(row); - } - }) -} - -fetchSchools(); - -function init() { - const schoolCreateForm = document.getElementById("schoolCreateForm"); - schoolCreateForm.addEventListener("submit", createSchool); -} - -function createSchool(e) { - e.preventDefault(); - const formData = new FormData(e.target); - const schoolData = { - name: formData.get("name"), - domain: formData.get("domain"), - }; - - fetch("/admin/api/schools", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(schoolData) - }) - .then(response => { - if (response.ok) { - return response.json(); - } else { - return response.json().then(data => { - throw new Error(data.message || "학교 생성에 실패하였습니다."); - }); - } - }) - .then(data => { - alert("학교가 성공적으로 생성되었습니다!"); - location.reload(); - }) - .catch(error => { - alert(error.message); - }); -} - -init(); diff --git a/backend/src/main/resources/static/js/admin/signup.js b/backend/src/main/resources/static/js/admin/signup.js deleted file mode 100644 index 34a391911..000000000 --- a/backend/src/main/resources/static/js/admin/signup.js +++ /dev/null @@ -1,38 +0,0 @@ -document.getElementById("signupForm").addEventListener("submit", - function (event) { - event.preventDefault(); - const formData = new FormData(event.target); - const username = formData.get("username"); - const password = formData.get("password"); - const confirmPassword = formData.get("confirmPassword"); - if (password !== confirmPassword) { - alert("비밀번호와 확인 비밀번호가 맞지 않습니다!") - return; - } - - const signupRequest = { - username: username, - password: password, - }; - - fetch("/admin/api/signup", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(signupRequest) - }) - .then(response => { - if (response.ok) { - event.target.reset(); - alert("계정이 생성되었습니다."); - } else { - return response.json().then(data => { - throw new Error(data.message || "해당 계정이 없거나 비밀번호가 틀립니다."); - }); - } - }) - .catch(error => { - alert(error.message); - }); - }); diff --git a/backend/src/main/resources/static/js/admin/stage/manage-stage-detail.js b/backend/src/main/resources/static/js/admin/stage/manage-stage-detail.js deleted file mode 100644 index 6a7c41814..000000000 --- a/backend/src/main/resources/static/js/admin/stage/manage-stage-detail.js +++ /dev/null @@ -1,227 +0,0 @@ -import {getResourceId} from "../../common/UrlParser.js"; - -const deleteConfirmModal = new bootstrap.Modal( - document.getElementById("deleteConfirmModal")); - -function fetchStage() { - const idInput = document.getElementById("id"); - const fakeIdInput = document.getElementById("fakeId"); - const festivalIdInput = document.getElementById("festivalId"); - const fakeFestivalIdInput = document.getElementById("fakeFestivalId"); - const startTimeInput = document.getElementById("startTime"); - const ticketOpenTime = document.getElementById("ticketOpenTime"); - const lineUpInput = document.getElementById("lineUp"); - const stageId = getResourceId(new URL(window.location.href)); - const errorModal = new bootstrap.Modal(document.getElementById("errorModal")); - const returnLink = document.getElementById("returnLink"); - - fetch(`/stages/${stageId}`).then(res => { - if (!res.ok) { - startTimeInput.setAttribute("disabled", ""); - ticketOpenTime.setAttribute("disabled", ""); - lineUpInput.setAttribute("disabled", ""); - return res.json().then(data => { - throw new Error(data.message || data.detail) - }) - } - return res.json(); - }).then(stage => { - idInput.value = stage.id; - fakeIdInput.value = stage.id; - festivalIdInput.value = stage.festivalId; - fakeFestivalIdInput.value = stage.festivalId; - startTimeInput.value = stage.startTime; - ticketOpenTime.value = stage.ticketOpenTime; - lineUpInput.value = stage.lineUp; - returnLink.setAttribute("href", `/admin/festivals/${stage.festivalId}`) - }).catch(error => { - const errorModalBody = document.getElementById("errorModalBody"); - errorModalBody.textContent = error.message; - errorModal.show(); - }) -} - -fetchStage(); - -function fetchTickets() { - const ticketGrid = document.getElementById("ticketGrid"); - const stageId = getResourceId(new URL(window.location.href)); - fetch(`/stages/${stageId}/tickets`).then(res => { - if (!res.ok) { - throw new Error("서버에 연결할 수 없습니다.") - } - return res.json(); - }).then(data => { - const tickets = data.tickets; - for (const ticket of tickets) { - const row = document.createElement("div"); - row.classList.add("row", "align-items-center", "gx-0", "py-1", - "border-top"); - - const idColumn = document.createElement("div"); - idColumn.classList.add("col-1"); - idColumn.textContent = ticket.id; - - const ticketTypeColumn = document.createElement("div"); - ticketTypeColumn.classList.add("col-3"); - ticketTypeColumn.textContent = ticket.ticketType; - - const totalAmountColumn = document.createElement("div"); - totalAmountColumn.classList.add("col-3"); - totalAmountColumn.textContent = ticket.totalAmount; - - const remainAmountColumn = document.createElement("div"); - remainAmountColumn.classList.add("col-3"); - remainAmountColumn.textContent = ticket.remainAmount; - - const buttonColumn = document.createElement("div"); - buttonColumn.classList.add("col-2") - const button = document.createElement("a"); - button.classList.add("btn", "btn-primary"); - button.setAttribute("href", `/admin/tickets/${ticket.id}`); - button.textContent = "편집"; - buttonColumn.append(button); - - row.append(idColumn, ticketTypeColumn, totalAmountColumn, - remainAmountColumn, buttonColumn); - ticketGrid.append(row); - } - }) -} - -fetchTickets(); - -function init() { - const schoolUpdateForm = document.getElementById("schoolUpdateForm"); - const ticketCreateForm = document.getElementById("ticketCreateForm"); - const deleteBtn = document.getElementById("deleteBtn"); - const actualDeleteBtn = document.getElementById("actualDeleteBtn"); - - schoolUpdateForm.addEventListener("submit", updateStage); - deleteBtn.addEventListener("click", openDeleteConfirmModal); - actualDeleteBtn.addEventListener("click", deleteStage) - ticketCreateForm.addEventListener("submit", createTicket) -} - -function updateStage(e) { - e.preventDefault(); - const formData = new FormData(e.target); - const stageData = { - startTime: formData.get("startTime"), - ticketOpenTime: formData.get("ticketOpenTime"), - lineUp: formData.get("lineUp"), - }; - const stageId = formData.get("id"); - fetch(`/admin/api/stages/${stageId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(stageData) - }) - .then(response => { - if (response.ok) { - return response; - } else { - return response.json().then(data => { - throw new Error(data.message || "공연 수정에 실패하였습니다."); - }); - } - }) - .then(() => { - alert("공연이 성공적으로 수정되었습니다!"); - location.reload(); - }) - .catch(error => { - alert(error.message); - }); -} - -function openDeleteConfirmModal() { - deleteConfirmModal.show(); -} - -function deleteStage() { - const stageId = document.getElementById("id").value; - const festivalId = document.getElementById("festivalId").value; - fetch(`/admin/api/stages/${stageId}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json" - } - }) - .then(response => { - if (response.ok) { - return response; - } else { - return response.json().then(data => { - throw new Error(data.message || "공연 삭제에 실패하였습니다."); - }); - } - }) - .then(() => { - alert("공연이 성공적으로 삭제되었습니다!"); - location.replace(`/admin/festivals/${festivalId}`); - }) - .catch(error => { - deleteConfirmModal.hide(); - alert(error.message); - }); -} - -function createTicket(e) { - e.preventDefault(); - const formData = new FormData(e.target); - const ticketData = { - stageId: document.getElementById("id").value, - ticketType: formData.get("ticketType"), - amount: formData.get("amount"), - entryTime: formData.get("entryTime"), - }; - validateTicket(ticketData); - - fetch("/admin/api/tickets", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(ticketData) - }) - .then(response => { - if (response.ok) { - return response; - } else { - return response.json().then(data => { - throw new Error(data.message || "티켓 생성에 실패하였습니다."); - }); - } - }) - .then(() => { - alert("티켓이 성공적으로 생성되었습니다!"); - location.reload(); - }) - .catch(error => { - alert(error.message); - }); -} - -function validateTicket(ticketData) { - let hasError = false; - if (ticketData.amount <= 0) { - document.getElementById("amount").classList.add("is-invalid"); - document.getElementById("amount-feedback") - .textContent = "수량은 0보다 많아야 합니다." - hasError = true; - } - if (ticketData.amount > 100000) { - document.getElementById("amount").classList.add("is-invalid"); - document.getElementById("amount-feedback") - .textContent = "너무 많은 수량은 생성할 수 없습니다." - hasError = true; - } - if (hasError) { - throw new Error("검증이 실패하였습니다."); - } -} - -init(); diff --git a/backend/src/main/resources/static/js/common/UrlParser.js b/backend/src/main/resources/static/js/common/UrlParser.js deleted file mode 100644 index 376f075ee..000000000 --- a/backend/src/main/resources/static/js/common/UrlParser.js +++ /dev/null @@ -1,5 +0,0 @@ -export function getResourceId(url) { - const pathname = url.pathname; - const parts = pathname.split("/"); - return parts[parts.length - 1]; -} diff --git a/backend/src/main/resources/static/js/login.js b/backend/src/main/resources/static/js/login.js deleted file mode 100644 index 6f4623be7..000000000 --- a/backend/src/main/resources/static/js/login.js +++ /dev/null @@ -1,29 +0,0 @@ -document.getElementById("loginForm").addEventListener("submit", - function (event) { - event.preventDefault(); - const formData = new FormData(event.target); - const loginRequest = { - username: formData.get("username"), - password: formData.get("password"), - }; - - fetch("/admin/api/login", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(loginRequest) - }) - .then(response => { - if (response.ok) { - return window.location.href = '/admin'; - } else { - return response.json().then(data => { - throw new Error(data.message || "해당 계정이 없거나 비밀번호가 틀립니다."); - }); - } - }) - .catch(error => { - alert(error.message); - }); - }); diff --git a/backend/src/main/resources/templates/admin/admin-page.html b/backend/src/main/resources/templates/admin/admin-page.html deleted file mode 100644 index 79b793b98..000000000 --- a/backend/src/main/resources/templates/admin/admin-page.html +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - 어드민 페이지 - - - -
-
-

어드민 페이지

-
-
- -
-
-
-

축제 관리

- 이동 -
-
-
- -
-
-
-

학교 관리

- 이동 -
-
-
- -
-
-
-

어드민 계정 생성

- 이동 -
-
-
- -
-
-
-

관리자 계정 관리(TODO)

- 이동 -
-
-
-
-
-
-

서버 버전 조회

- -
-
-
- -
-
-
-

INFO 로그 생성

- -
-
-
- -
-
-
-

WARN 로그 생성

- -
-
-
- -
-
-
-

ERROR 로그 생성

- -
-
-
- -
-
- - - - diff --git a/backend/src/main/resources/templates/admin/festival/manage-festival-detail.html b/backend/src/main/resources/templates/admin/festival/manage-festival-detail.html deleted file mode 100644 index ca4e1ea26..000000000 --- a/backend/src/main/resources/templates/admin/festival/manage-festival-detail.html +++ /dev/null @@ -1,154 +0,0 @@ - - - - - - 어드민 페이지 - 축제 세부 관리 - - - -
- -
-
-
-

축제 수정

-
-
- - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- -
-
-
- -
-
-
-
-

공연 등록

-
-
-
- - -
-
-
- - -
-
-
- - -
-
- -
-
-
-
-
-
-

공연 목록

-
-
-
-
- ID -
-
- 시작시간 -
-
- 티켓 오픈 시간 -
-
- 라인업 -
-
-
-
- - - - - - diff --git a/backend/src/main/resources/templates/admin/festival/manage-festival.html b/backend/src/main/resources/templates/admin/festival/manage-festival.html deleted file mode 100644 index 759f9462c..000000000 --- a/backend/src/main/resources/templates/admin/festival/manage-festival.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - 어드민 페이지 - 축제 관리 - - - -
- -
-

축제 생성

-
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- -
-
- -
-
-
-
-

축제 목록

-
-
-
-
- ID -
-
- 학교 ID -
-
- 이름 -
-
- 썸네일 -
-
- 시작일 -
-
- 종료일 -
-
-
-
- - - - diff --git a/backend/src/main/resources/templates/admin/login.html b/backend/src/main/resources/templates/admin/login.html deleted file mode 100644 index 28ee7be87..000000000 --- a/backend/src/main/resources/templates/admin/login.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - Login - - - - - - - diff --git a/backend/src/main/resources/templates/admin/school/manage-school-detail.html b/backend/src/main/resources/templates/admin/school/manage-school-detail.html deleted file mode 100644 index fdc39c24a..000000000 --- a/backend/src/main/resources/templates/admin/school/manage-school-detail.html +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - 어드민 페이지 - 학교 세부 관리 - - - -
- -
-

학교 수정

-
-
- -
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
- - - - - - diff --git a/backend/src/main/resources/templates/admin/school/manage-school.html b/backend/src/main/resources/templates/admin/school/manage-school.html deleted file mode 100644 index 95475ad8d..000000000 --- a/backend/src/main/resources/templates/admin/school/manage-school.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - 어드민 페이지 - 학교 관리 - - - -
- -
-

학교 생성

-
-
-
- - -
-
- - -
-
- -
-
-
-
-

학교 목록

-
-
-
-
- ID -
-
- 이름 -
-
- 도메인 -
-
-
-
- - - - diff --git a/backend/src/main/resources/templates/admin/signup.html b/backend/src/main/resources/templates/admin/signup.html deleted file mode 100644 index 422870c37..000000000 --- a/backend/src/main/resources/templates/admin/signup.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - Signup - - - - - - - diff --git a/backend/src/main/resources/templates/admin/stage/manage-stage-detail.html b/backend/src/main/resources/templates/admin/stage/manage-stage-detail.html deleted file mode 100644 index 72657ccbe..000000000 --- a/backend/src/main/resources/templates/admin/stage/manage-stage-detail.html +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - 어드민 페이지 - 공연 세부 관리 - - - -
- -
-
-
-

공연 수정

-
-
- - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
-
-

티켓 생성

-
-
-
- -
-
- - -
-
-
- - -
-
-
- -
-
-
-
- -
-
-

티켓 목록

-
-
-
-
- ID -
-
- 티켓 종류 -
-
- 총 수량 -
-
- 남은 수량 -
-
-
-
- - - - - - diff --git a/backend/src/test/java/com/festago/acceptance/CucumberClient.java b/backend/src/test/java/com/festago/acceptance/CucumberClient.java index e0655ec16..80c8b85c5 100644 --- a/backend/src/test/java/com/festago/acceptance/CucumberClient.java +++ b/backend/src/test/java/com/festago/acceptance/CucumberClient.java @@ -4,9 +4,9 @@ import io.restassured.response.Response; import java.util.HashMap; import java.util.Map; -import org.springframework.stereotype.Component; +import org.springframework.boot.test.context.TestComponent; -@Component +@TestComponent @ScenarioScope public class CucumberClient { @@ -21,7 +21,7 @@ public void addData(String key, Object value) { dataStorage.put(key, value); } - public void addAuthToken(String token) { + public void setToken(String token) { this.token = token; } diff --git a/backend/src/test/java/com/festago/acceptance/CucumberSpringConfiguration.java b/backend/src/test/java/com/festago/acceptance/CucumberSpringConfiguration.java index 97faaca9e..b2acd808e 100644 --- a/backend/src/test/java/com/festago/acceptance/CucumberSpringConfiguration.java +++ b/backend/src/test/java/com/festago/acceptance/CucumberSpringConfiguration.java @@ -1,26 +1,31 @@ package com.festago.acceptance; +import com.festago.support.DatabaseClearTestExecutionListener; +import com.festago.support.ResetMockTestExecutionListener; +import com.festago.support.TestTimeConfig; import io.cucumber.java.Before; import io.cucumber.spring.CucumberContextConfiguration; import io.restassured.RestAssured; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestExecutionListeners; +@Import({CucumberClient.class, TestTimeConfig.class}) @CucumberContextConfiguration @SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT) +@TestExecutionListeners(listeners = { + DatabaseClearTestExecutionListener.class, + ResetMockTestExecutionListener.class +}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS) public class CucumberSpringConfiguration { @LocalServerPort private int port; - @Autowired - private DataInitializer dataInitializer; - @Before public void before() { - dataInitializer.execute(); RestAssured.port = port; } } diff --git a/backend/src/test/java/com/festago/acceptance/DataInitializer.java b/backend/src/test/java/com/festago/acceptance/DataInitializer.java deleted file mode 100644 index 0fccf6c67..000000000 --- a/backend/src/test/java/com/festago/acceptance/DataInitializer.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.festago.acceptance; - -import java.sql.DatabaseMetaData; -import java.sql.ResultSet; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import javax.sql.DataSource; -import org.springframework.beans.factory.InitializingBean; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Profile; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@Profile("test") -public class DataInitializer implements InitializingBean { - - @Autowired - private JdbcTemplate jdbcTemplate; - - @Autowired - private DataSource dataSource; - - private List tableNames; - - private Set metaTableNames = Set.of( - "sys_config", - "flyway_schema_history", - "CONSTANTS", - "ENUM_VALUES", - "INDEXES", - "INDEX_COLUMNS", - "INFORMATION_SCHEMA_CATALOG_NAME", - "IN_DOUBT", - "LOCKS", - "QUERY_STATISTICS", - "RIGHTS", - "ROLES", - "SESSIONS", - "SESSION_STATE", - "SETTINGS", - "SYNONYMS", - "USERS" - ); - - @Override - public void afterPropertiesSet() { - tableNames = new ArrayList<>(); - try { - DatabaseMetaData metaData = dataSource.getConnection().getMetaData(); - ResultSet tables = metaData.getTables(null, null, null, new String[]{"TABLE"}); - while (tables.next()) { - String tableName = tables.getString("TABLE_NAME"); - if (metaTableNames.contains(tableName)) { - continue; - } - tableNames.add(tableName); - } - } catch (Exception e) { - throw new RuntimeException(); - } - } - - @Transactional - public void execute() { - truncateAllTables(); - } - - private void truncateAllTables() { - jdbcTemplate.execute("SET foreign_key_checks = 0;"); - tableNames.forEach( - tableName -> executeQueryWithTable(tableName) - ); - jdbcTemplate.execute("SET foreign_key_checks = 1;"); - } - - private void executeQueryWithTable(String tableName) { - jdbcTemplate.execute("TRUNCATE TABLE " + tableName); - } -} - diff --git a/backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java b/backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java new file mode 100644 index 000000000..4d6fcf882 --- /dev/null +++ b/backend/src/test/java/com/festago/acceptance/steps/AdminSchoolStepDefinitions.java @@ -0,0 +1,103 @@ +package com.festago.acceptance.steps; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.acceptance.CucumberClient; +import com.festago.admin.dto.school.AdminSchoolV1Response; +import com.festago.admin.dto.school.SchoolV1CreateRequest; +import com.festago.admin.dto.school.SchoolV1UpdateRequest; +import com.festago.school.domain.SchoolRegion; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; + +public class AdminSchoolStepDefinitions { + + @Autowired + CucumberClient cucumberClient; + + @Given("지역이 {string}에 있고, 이름이 {string}이고, 도메인이 {string}인 학교를 생성한다.") + public void 학교를_생성한다(String region, String name, String domain) { + var request = SchoolV1CreateRequest.builder() + .name(name) + .domain(domain) + .region(SchoolRegion.valueOf(region)) + .build(); + RestAssured.given() + .contentType(ContentType.JSON) + .body(request) + .cookie("token", cucumberClient.getToken()) + .post("/admin/api/v1/schools") + .then() + .log().ifError() + .statusCode(201) + .extract() + .header("Location"); + } + + @When("이름이 {string}인 학교의 이름을 {string}로 변경한다.") + public void 학교의_이름을_다른_이름으로_변경한다(String srcName, String dstName) { + var response = getSchoolResponsesByName(srcName).get(0); + var request = SchoolV1UpdateRequest.builder() + .name(dstName) + .domain(response.domain()) + .region(response.region()) + .build(); + RestAssured.given() + .contentType(ContentType.JSON) + .body(request) + .cookie("token", cucumberClient.getToken()) + .pathParam("id", response.id()) + .patch("/admin/api/v1/schools/{id}") + .then() + .log().ifError() + .statusCode(200); + } + + public List getSchoolResponsesByName(String src) { + return RestAssured.given() + .contentType(ContentType.JSON) + .queryParams(Map.of("searchFilter", "name", "searchKeyword", src)) + .cookie("token", cucumberClient.getToken()) + .get("/admin/api/v1/schools") + .then() + .log().ifError() + .statusCode(200) + .extract() + .body() + .jsonPath() + .getList("content", AdminSchoolV1Response.class); + } + + @When("이름이 {string}인 학교를 삭제한다.") + public void 특정_이름의_학교를_삭제한다(String name) { + var response = getSchoolResponsesByName(name).get(0); + RestAssured.given() + .contentType(ContentType.JSON) + .cookie("token", cucumberClient.getToken()) + .pathParam("id", response.id()) + .delete("/admin/api/v1/schools/{id}") + .then() + .log().ifError() + .statusCode(204); + } + + @Then("이름에 {string}가 포함된 학교가 조회되어야 한다.") + public void 이름이_포함된_학교를_조회한다(String name) { + var expect = getSchoolResponsesByName(name); + + assertThat(expect).isNotEmpty(); + } + + @Then("이름에 {string}가 포함된 학교가 조회되지 않는다.") + public void 이름이_포함된_학교가_조회되지_않는다(String name) { + var expect = getSchoolResponsesByName(name); + + assertThat(expect).isEmpty(); + } +} diff --git a/backend/src/test/java/com/festago/acceptance/steps/AdminStepDefinitions.java b/backend/src/test/java/com/festago/acceptance/steps/AdminStepDefinitions.java new file mode 100644 index 000000000..c45f169eb --- /dev/null +++ b/backend/src/test/java/com/festago/acceptance/steps/AdminStepDefinitions.java @@ -0,0 +1,31 @@ +package com.festago.acceptance.steps; + +import com.festago.acceptance.CucumberClient; +import com.festago.auth.application.command.AdminAuthCommandService; +import com.festago.auth.dto.command.AdminLoginCommand; +import io.cucumber.java.en.Given; +import org.springframework.beans.factory.annotation.Autowired; + +public class AdminStepDefinitions { + + @Autowired + CucumberClient cucumberClient; + + @Autowired + AdminAuthCommandService adminAuthCommandService; + + @Given("어드민 계정으로 로그인한다.") + public void loginAdmin() { + AdminLoginCommand command = AdminLoginCommand.builder() + .username("admin") + .password("1234") + .build(); + var adminLoginResult = adminAuthCommandService.login(command); + cucumberClient.setToken(adminLoginResult.accessToken()); + } + + @Given("어드민 계정을 활성화한다.") + public void initializeAdmin() { + adminAuthCommandService.initializeRootAdmin("1234"); + } +} diff --git a/backend/src/test/java/com/festago/acceptance/steps/ExampleStep.java b/backend/src/test/java/com/festago/acceptance/steps/ExampleStep.java deleted file mode 100644 index 6ac0e7cf3..000000000 --- a/backend/src/test/java/com/festago/acceptance/steps/ExampleStep.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.festago.acceptance.steps; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.festago.acceptance.CucumberClient; -import com.festago.auth.dto.AdminLoginRequest; -import com.festago.auth.dto.RootAdminInitializeRequest; -import com.festago.festival.dto.FestivalCreateRequest; -import com.festago.festival.dto.FestivalDetailResponse; -import com.festago.festival.dto.FestivalResponse; -import com.festago.festival.dto.FestivalsResponse; -import com.festago.school.dto.SchoolCreateRequest; -import com.festago.school.dto.SchoolResponse; -import io.cucumber.java.en.Given; -import io.cucumber.java.en.Then; -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; -import java.time.LocalDate; -import org.springframework.beans.factory.annotation.Autowired; - -public class ExampleStep { - - @Autowired - CucumberClient cucumberClient; - - @Given("로그인을 한 상태에서") - public void login() { - String password = "password"; - RestAssured.given() - .contentType(ContentType.JSON) - .body(new RootAdminInitializeRequest(password)) - .post("admin/api/initialize") - .then() - .statusCode(200); - - ExtractableResponse response = RestAssured.given() - .contentType(ContentType.JSON) - .body(new AdminLoginRequest("admin", password)) - .post("admin/api/login") - .then() - .extract(); - - cucumberClient.addAuthToken(response.cookie("token")); - } - - @Given("축제를 생성하고") - public void given() { - SchoolResponse schoolResponse = (SchoolResponse) cucumberClient.getData("schoolResponse"); - FestivalCreateRequest request = new FestivalCreateRequest("푸우 축제", LocalDate.now(), - LocalDate.now().plusDays(1), "thumnail", schoolResponse.id()); - FestivalResponse response = RestAssured.given() - .contentType(ContentType.JSON) - .cookie("token", cucumberClient.getToken()) - .body(request) - .post("admin/api/festivals") - .then() - .extract() - .body() - .as(FestivalResponse.class); - cucumberClient.addData("festivalData", response); - } - - @Given("{string}를 생성하고") - public void makeSchool(String schoolName) { - SchoolCreateRequest request = new SchoolCreateRequest(schoolName, "domain.com"); - SchoolResponse response = RestAssured.given() - .contentType(ContentType.JSON) - .cookie("token", cucumberClient.getToken()) - .body(request) - .post("admin/api/schools") - .then() - .extract() - .body() - .as(SchoolResponse.class); - cucumberClient.addData("schoolResponse", response); - } - - @Then("축제가 있다") - public void then() { - FestivalDetailResponse response = (FestivalDetailResponse) cucumberClient.getData("searchResult"); - assertThat(response.name()).isEqualTo("푸우 축제"); - } - - @Then("전 시나리오에서 생성된 데이터는 없어진다") - public void exist() { - FestivalsResponse as = RestAssured.given() - .when() - .get("/festivals") - .then() - .extract() - .as(FestivalsResponse.class); - - assertThat(as.festivals().size()).isEqualTo(0); - } - -} diff --git a/backend/src/test/java/com/festago/acceptance/steps/ExampleStep2.java b/backend/src/test/java/com/festago/acceptance/steps/ExampleStep2.java deleted file mode 100644 index b54fd25d1..000000000 --- a/backend/src/test/java/com/festago/acceptance/steps/ExampleStep2.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.festago.acceptance.steps; - -import com.festago.acceptance.CucumberClient; -import com.festago.festival.dto.FestivalDetailResponse; -import com.festago.festival.dto.FestivalResponse; -import io.cucumber.java.en.When; -import io.restassured.RestAssured; -import org.springframework.beans.factory.annotation.Autowired; - -public class ExampleStep2 { - - @Autowired - CucumberClient cucumberClient; - - @When("축제를 검색하면") - public void given() { - FestivalResponse response = (FestivalResponse) cucumberClient.getData("festivalData"); - FestivalDetailResponse festivalInfo = RestAssured.given() - .when() - .get("festivals/{festivalId}", response.id()) - .then() - .extract() - .as(FestivalDetailResponse.class); - cucumberClient.addData("searchResult", festivalInfo); - } -} diff --git a/backend/src/test/java/com/festago/acceptance/steps/FestivalStepDefinitions.java b/backend/src/test/java/com/festago/acceptance/steps/FestivalStepDefinitions.java new file mode 100644 index 000000000..c620e726a --- /dev/null +++ b/backend/src/test/java/com/festago/acceptance/steps/FestivalStepDefinitions.java @@ -0,0 +1,73 @@ +package com.festago.acceptance.steps; + +import static org.hamcrest.Matchers.is; + +import com.festago.acceptance.CucumberClient; +import com.festago.festival.application.command.FestivalCreateService; +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.school.repository.SchoolRepository; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.util.Assert; + +public class FestivalStepDefinitions { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"); + private static final String FESTIVALS_KEY = "축제목록"; + + @Autowired + CucumberClient cucumberClient; + + @Autowired + FestivalCreateService festivalCreateService; + + @Autowired + SchoolRepository schoolRepository; + + @Given("{string}에서 시작일이 {string}, 종료일이 {string}, 이름이 {string}인 축제를 생성한다.") + public void 축제를_생성한다(String 학교이름, String 시작일, String 종료일, String 축제이름) { + LocalDate startDate = LocalDate.parse(시작일, DATE_TIME_FORMATTER); + LocalDate endDate = LocalDate.parse(종료일, DATE_TIME_FORMATTER); + Long schoolId = schoolRepository.findByName(학교이름).get().getId(); + var command = FestivalCreateCommand.builder() + .name(축제이름) + .startDate(startDate) + .endDate(endDate) + .posterImageUrl("https://image.com/image.png") + .schoolId(schoolId) + .build(); + festivalCreateService.createFestival(command); + } + + @Then("상태가 {string}인 축제를 조회하면 {int}개의 축제가 조회된다.") + public void 특정_상태의_축제를_조회하면_n개의_축제가_조회된다(String status, int size) { + var response = getFestivalsByStatus(status); + cucumberClient.addData(FESTIVALS_KEY, response); + response.body("content.size()", is(size)); + } + + private ValidatableResponse getFestivalsByStatus(String status) { + return RestAssured.given() + .contentType(ContentType.JSON) + .queryParam("filter", status) + .get("/api/v1/festivals") + .then() + .log().body() + .statusCode(200); + } + + @And("조회된 축제 중에서 {int}번째 축제의 이름은 {string} 이어야 한다.") + public void 특정_상태인_축제를_조회할_때_n번째로_조회된_축제의_이름을_검증한다(int index, String name) { + var response = (ValidatableResponse) cucumberClient.getData(FESTIVALS_KEY); + Assert.notNull(response, "조회된 축제가 없습니다."); + + response.body("content[" + (index - 1) + "].name", is(name)); + } +} diff --git a/backend/src/test/java/com/festago/acceptance/steps/TimeStepDefinitions.java b/backend/src/test/java/com/festago/acceptance/steps/TimeStepDefinitions.java new file mode 100644 index 000000000..7e4d6ecab --- /dev/null +++ b/backend/src/test/java/com/festago/acceptance/steps/TimeStepDefinitions.java @@ -0,0 +1,40 @@ +package com.festago.acceptance.steps; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.festago.support.TimeInstantProvider; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import java.time.Clock; +import java.time.LocalDateTime; +import org.springframework.beans.factory.annotation.Autowired; + +public class TimeStepDefinitions { + + @Autowired + Clock clock; + + @Given("현재 시간은 {int}년 {int}월 {int}일 {int}시 {int}분 이다.") + public void 시간을_설정한다(int 년, int 월, int 일, int 시, int 분) { + LocalDateTime localDateTime = LocalDateTime.of(년, 월, 일, 시, 분); + given(clock.instant()) + .willReturn(TimeInstantProvider.from(localDateTime)); + } + + @Then("현재 시간은 {int}년 {int}월 {int}일 {int}시 {int}분이 되어야 한다.") + public void 시간이_같아야_한다(int 년, int 월, int 일, int 시, int 분) { + LocalDateTime expect = LocalDateTime.of(년, 월, 일, 시, 분); + LocalDateTime actual = LocalDateTime.now(clock); + + assertThat(expect).isEqualTo(actual); + } + + @Then("현재 시간은 {int}년 {int}월 {int}일 {int}시 {int}분이 아니어야 한다.") + public void 시간은_달라야_한다(int 년, int 월, int 일, int 시, int 분) { + LocalDateTime expect = LocalDateTime.of(년, 월, 일, 시, 분); + LocalDateTime actual = LocalDateTime.now(clock); + + assertThat(expect).isNotEqualTo(actual); + } +} diff --git a/backend/src/test/java/com/festago/admin/application/integration/AdminArtistV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/admin/application/integration/AdminArtistV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..48c920093 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/application/integration/AdminArtistV1QueryServiceIntegrationTest.java @@ -0,0 +1,177 @@ +package com.festago.admin.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.admin.application.AdminArtistV1QueryService; +import com.festago.admin.dto.artist.AdminArtistV1Response; +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.querydsl.SearchCondition; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.ArtistFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminArtistV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + AdminArtistV1QueryService adminArtistV1QueryService; + + @Autowired + ArtistRepository artistRepository; + + @Test + void 아티스트를_단건_조회한다() { + // given + Artist expected = artistRepository.save(ArtistFixture.builder().build()); + + // when + var actual = adminArtistV1QueryService.findById(expected.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual.id()).isEqualTo(expected.getId()); + softly.assertThat(actual.name()).isEqualTo(expected.getName()); + softly.assertThat(actual.profileImageUrl()).isEqualTo(expected.getProfileImage()); + softly.assertThat(actual.backgroundImageUrl()).isEqualTo(expected.getBackgroundImageUrl()); + }); + } + + @Nested + class findAll { + + Artist 벤; + Artist 베토벤; + Artist 아이유; + Artist 에픽하이; + Artist 소녀시대; + + @BeforeEach + void setUp() { + 벤 = artistRepository.save(ArtistFixture.builder() + .name("벤") + .build()); + 베토벤 = artistRepository.save(ArtistFixture.builder() + .name("베토벤") + .build()); + 아이유 = artistRepository.save(ArtistFixture.builder() + .name("아이유") + .build()); + 에픽하이 = artistRepository.save(ArtistFixture.builder() + .name("에픽하이") + .build()); + 소녀시대 = artistRepository.save(ArtistFixture.builder() + .name("소녀시대") + .build()); + } + + @Test + void 정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "name")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminArtistV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminArtistV1Response::name) + .containsExactly(베토벤.getName(), 벤.getName(), 소녀시대.getName(), 아이유.getName(), 에픽하이.getName()); + } + + @Test + void 식별자로_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", 소녀시대.getId().toString(), pageable); + + // when + var response = adminArtistV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminArtistV1Response::name) + .containsExactlyInAnyOrder(소녀시대.getName()); + } + + @Test + void 이름이_포함된_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("name", "에픽", pageable); + + // when + var response = adminArtistV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminArtistV1Response::name) + .containsExactly(에픽하이.getName()); + } + + @Test + void 이름으로_검색할때_한_글자이면_동등_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("name", "벤", pageable); + + // when + var response = adminArtistV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminArtistV1Response::name) + .containsExactly(벤.getName()); + } + + @Test + void 검색_필터가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("", "글렌", pageable); + + // when + var response = adminArtistV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(5); + } + + @Test + void 검색어가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", "", pageable); + + // when + var response = adminArtistV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(5); + } + + @Test + void 페이지네이션이_적용_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 2); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminArtistV1QueryService.findAll(searchCondition); + + // then + assertSoftly(softly -> { + softly.assertThat(response.getSize()).isEqualTo(2); + softly.assertThat(response.getTotalPages()).isEqualTo(3); + softly.assertThat(response.getTotalElements()).isEqualTo(5); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/admin/application/integration/AdminFestivalV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/admin/application/integration/AdminFestivalV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..328f67746 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/application/integration/AdminFestivalV1QueryServiceIntegrationTest.java @@ -0,0 +1,337 @@ +package com.festago.admin.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.admin.application.AdminFestivalV1QueryService; +import com.festago.admin.dto.festival.AdminFestivalV1Response; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.querydsl.SearchCondition; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminFestivalV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + AdminFestivalV1QueryService adminFestivalV1QueryService; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + StageRepository stageRepository; + + LocalDate now = LocalDate.parse("2077-06-30"); + LocalDate tomorrow = now.plusDays(1); + + School 테코대학교; + School 우테대학교; + Festival 테코대학교_축제; + Festival 테코대학교_공연_없는_축제; + Festival 우테대학교_축제; + Stage 테코대학교_공연; + Stage 우테대학교_첫째날_공연; + Stage 우테대학교_둘째날_공연; + + @BeforeEach + void setUp() { + LocalDateTime ticketOpenTime = now.atStartOfDay().minusWeeks(1); + 우테대학교 = schoolRepository.save(SchoolFixture.builder() + .name("우테대학교") + .region(SchoolRegion.서울) + .build()); + 테코대학교 = schoolRepository.save(SchoolFixture.builder() + .name("테코대학교") + .region(SchoolRegion.서울) + .build()); + + 테코대학교_축제 = festivalRepository.save(FestivalFixture.builder() + .name("테코대학교 축제") + .startDate(now) + .endDate(now) + .school(테코대학교) + .build()); + 테코대학교_공연_없는_축제 = festivalRepository.save(FestivalFixture.builder() + .name("테코대학교 공연 없는 축제") + .startDate(tomorrow) + .endDate(tomorrow) + .school(테코대학교) + .build()); + 우테대학교_축제 = festivalRepository.save(FestivalFixture.builder() + .name("우테대학교 축제") + .startDate(now) + .endDate(tomorrow) + .school(우테대학교) + .build()); + + 테코대학교_공연 = stageRepository.save(StageFixture.builder() + .startTime(now.atTime(18, 0)) + .ticketOpenTime(ticketOpenTime) + .festival(테코대학교_축제) + .build()); + 우테대학교_첫째날_공연 = stageRepository.save(StageFixture.builder() + .startTime(now.atTime(18, 0)) + .ticketOpenTime(ticketOpenTime) + .festival(우테대학교_축제) + .build()); + 우테대학교_둘째날_공연 = stageRepository.save(StageFixture.builder() + .startTime(tomorrow.atTime(18, 0)) + .ticketOpenTime(ticketOpenTime) + .festival(우테대학교_축제) + .build()); + } + + @Nested + class findAll { + + @Test + void 페이지네이션이_적용되어야_한다() { + // given + Pageable pageable = PageRequest.ofSize(2); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertSoftly(softly -> { + softly.assertThat(response.getSize()).isEqualTo(2); + softly.assertThat(response.getTotalPages()).isEqualTo(2); + softly.assertThat(response.getTotalElements()).isEqualTo(3); + }); + } + + @Test + void 공연의_수가_정확하게_반환되어야_한다() { + // given + Pageable pageable = PageRequest.ofSize(10); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::stageCount) + .containsExactly(1L, 0L, 2L); + } + + @Nested + class 정렬 { + + @Test + void 축제의_식별자로_정렬_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "id")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(우테대학교_축제.getId(), 테코대학교_공연_없는_축제.getId(), 테코대학교_축제.getId()); + } + + @Test + void 축제의_이름으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "name")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::name) + .containsExactly(우테대학교_축제.getName(), 테코대학교_공연_없는_축제.getName(), 테코대학교_축제.getName()); + } + + @Test + void 학교의_이름으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "schoolName")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(우테대학교_축제.getId(), 테코대학교_축제.getId(), 테코대학교_공연_없는_축제.getId()); + } + + @Test + void 축제의_시작일으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "startDate")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_축제.getId(), 우테대학교_축제.getId(), 테코대학교_공연_없는_축제.getId()); + } + + @Test + void 축제의_종료일으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "endDate")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_공연_없는_축제.getId(), 우테대학교_축제.getId(), 테코대학교_축제.getId()); + } + + @Test + void 정렬_조건에_없으면_식별자의_오름차순으로_정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "foo")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + // then + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_축제.getId(), 테코대학교_공연_없는_축제.getId(), 우테대학교_축제.getId()); + } + } + + @Nested + class 검색 { + + @Test + void 축제의_식별자로_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", 테코대학교_축제.getId().toString(), pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_축제.getId()); + } + + @Test + void 축제의_이름이_포함된_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("name", "테코대학교", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(테코대학교_축제.getId(), 테코대학교_공연_없는_축제.getId()); + } + + @Test + void 학교의_이름이_포함된_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("schoolName", "우테대학교", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminFestivalV1Response::id) + .containsExactly(우테대학교_축제.getId()); + } + + @Test + void 검색_필터가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("", "글렌", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(3); + } + + @Test + void 검색어가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", "", pageable); + + // when + var response = adminFestivalV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(3); + } + } + } + + @Nested + class findDetail { + + @Test + void 축제의_식별자로_조회할_수_있어야_한다() { + // when + var actual = adminFestivalV1QueryService.findDetail(우테대학교_축제.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual.id()).isEqualTo(우테대학교_축제.getId()); + softly.assertThat(actual.name()).isEqualTo(우테대학교_축제.getName()); + softly.assertThat(actual.schoolId()).isEqualTo(우테대학교.getId()); + softly.assertThat(actual.schoolName()).isEqualTo(우테대학교.getName()); + }); + } + + @Test + void 축제의_식별자에_해당하는_축제가_없으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> adminFestivalV1QueryService.findDetail(4885L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.FESTIVAL_NOT_FOUND.getMessage()); + } + } +} diff --git a/backend/src/test/java/com/festago/admin/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/admin/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..bb9f5b968 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/application/integration/AdminSchoolV1QueryServiceIntegrationTest.java @@ -0,0 +1,204 @@ +package com.festago.admin.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.admin.application.AdminSchoolV1QueryService; +import com.festago.admin.dto.school.AdminSchoolV1Response; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.querydsl.SearchCondition; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.SchoolFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminSchoolV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + AdminSchoolV1QueryService adminSchoolV1QueryService; + + @Autowired + SchoolRepository schoolRepository; + + School 테코대학교; + School 우테대학교; + School 글렌대학교; + + @BeforeEach + void setUp() { + 테코대학교 = schoolRepository.save(SchoolFixture.builder() + .name("테코대학교") + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .build()); + 우테대학교 = schoolRepository.save(SchoolFixture.builder() + .name("우테대학교") + .domain("wote.ac.kr") + .region(SchoolRegion.서울) + .build()); + 글렌대학교 = schoolRepository.save(SchoolFixture.builder() + .name("글렌대학교") + .domain("glen.ac.kr") + .region(SchoolRegion.대구) + .build()); + } + + @Nested + class findAll { + + @Test + void 정렬이_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "name")); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactly(글렌대학교.getName(), 우테대학교.getName(), 테코대학교.getName()); + } + + @Test + void 식별자로_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", 글렌대학교.getId().toString(), pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactlyInAnyOrder(글렌대학교.getName()); + } + + @Test + void 지역으로_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("region", "서울", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactlyInAnyOrder(우테대학교.getName(), 테코대학교.getName()); + } + + @Test + void 도메인이_포함된_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("domain", "wote", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactly(우테대학교.getName()); + } + + @Test + void 이름이_포함된_검색이_되어야_한다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("name", "글렌", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .map(AdminSchoolV1Response::name) + .containsExactly(글렌대학교.getName()); + } + + @Test + void 검색_필터가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("", "글렌", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(3); + } + + @Test + void 검색어가_비어있으면_필터링이_적용되지_않는다() { + // given + Pageable pageable = Pageable.ofSize(10); + SearchCondition searchCondition = new SearchCondition("id", "", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + assertThat(response.getContent()) + .hasSize(3); + } + + @Test + void 페이지네이션이_적용_되어야_한다() { + // given + Pageable pageable = PageRequest.of(0, 2); + SearchCondition searchCondition = new SearchCondition("", "", pageable); + + // when + Page response = adminSchoolV1QueryService.findAll(searchCondition); + + // then + assertSoftly(softly -> { + softly.assertThat(response.getSize()).isEqualTo(2); + softly.assertThat(response.getTotalPages()).isEqualTo(2); + softly.assertThat(response.getTotalElements()).isEqualTo(3); + }); + } + } + + @Nested + class findById { + + @Test + void 식별자로_조회가_되어야_한다() { + // given + Long 테코대학교_식별자 = 테코대학교.getId(); + + // when + AdminSchoolV1Response response = adminSchoolV1QueryService.findById(테코대학교_식별자); + + // then + assertThat(response.name()).isEqualTo("테코대학교"); + } + + @Test + void 식별자로_찾을_수_없으면_예외가_발생한다() { + // given + Long invalidId = 4885L; + + // when + assertThatThrownBy(() -> adminSchoolV1QueryService.findById(invalidId)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.SCHOOL_NOT_FOUND.getMessage()); + } + } +} diff --git a/backend/src/test/java/com/festago/admin/application/integration/AdminSocialMediaV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/admin/application/integration/AdminSocialMediaV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..e69ea57ce --- /dev/null +++ b/backend/src/test/java/com/festago/admin/application/integration/AdminSocialMediaV1QueryServiceIntegrationTest.java @@ -0,0 +1,88 @@ +package com.festago.admin.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.admin.application.AdminSocialMediaV1QueryService; +import com.festago.admin.dto.socialmedia.AdminSocialMediaV1Response; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.repository.SocialMediaRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.SocialMediaFixture; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminSocialMediaV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + AdminSocialMediaV1QueryService adminSocialMediaV1QueryService; + + @Autowired + SocialMediaRepository socialMediaRepository; + + @Nested + class findById { + + @Test + void 소셜미디어_식별자로_조회할_수_있다() { + // given + Long 테코대학교_식별자 = 1L; + Long 소셜미디어_식별자 = socialMediaRepository.save(SocialMediaFixture.builder() + .ownerId(테코대학교_식별자) + .ownerType(OwnerType.SCHOOL) + .name("테코대학교 소셜미디어") + .build()).getId(); + + // when + var actual = adminSocialMediaV1QueryService.findById(소셜미디어_식별자); + + // then + assertThat(actual.name()).isEqualTo("테코대학교 소셜미디어"); + } + + @Test + void 식별자에_대한_소셜미디어가_존재하지_않으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> adminSocialMediaV1QueryService.findById(4885L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.SOCIAL_MEDIA_NOT_FOUND.getMessage()); + } + } + + @Nested + class findByOwnerIdAndOwnerType { + + @Test + void ownerId와_ownerType으로_해당하는_소셜미디어를_모두_조회할_수_있다() { + // given + Long 테코대학교_식별자 = 1L; + var expect = Stream.of(SocialMediaType.INSTAGRAM, SocialMediaType.X, SocialMediaType.YOUTUBE) + .map(mediaType -> socialMediaRepository.save(SocialMediaFixture.builder() + .ownerId(테코대학교_식별자) + .ownerType(OwnerType.SCHOOL) + .mediaType(mediaType) + .build()) + ) + .map(SocialMedia::getId) + .toList(); + + // when + var actual = adminSocialMediaV1QueryService.findByOwnerIdAndOwnerType(테코대학교_식별자, OwnerType.SCHOOL); + + // then + assertThat(actual) + .map(AdminSocialMediaV1Response::id) + .containsExactlyInAnyOrderElementsOf(expect); + } + } +} diff --git a/backend/src/test/java/com/festago/admin/application/integration/AdminStageV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/admin/application/integration/AdminStageV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..5fbc43a08 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/application/integration/AdminStageV1QueryServiceIntegrationTest.java @@ -0,0 +1,245 @@ +package com.festago.admin.application.integration; + +import static java.util.stream.Collectors.toMap; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.admin.application.AdminStageV1QueryService; +import com.festago.admin.dto.stage.AdminStageArtistV1Response; +import com.festago.admin.dto.stage.AdminStageV1Response; +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageArtistFixture; +import com.festago.support.fixture.StageFixture; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminStageV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + AdminStageV1QueryService adminStageV1QueryService; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + ArtistRepository artistRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + StageArtistRepository stageArtistRepository; + + LocalDate _2077년_6월_15일 = LocalDate.parse("2077-06-15"); + LocalDate _2077년_6월_16일 = LocalDate.parse("2077-06-16"); + LocalDate _2077년_6월_17일 = LocalDate.parse("2077-07-17"); + + @Nested + class findAllByFestivalId { + + @Test + void 존재하지_않는_축제의_식별자로_조회하면_빈_리스트가_반환된다() { + // when + var actual = adminStageV1QueryService.findAllByFestivalId(4885L); + + // then + assertThat(actual).isEmpty(); + } + + @Nested + class 축제에_공연이_없으면 { + + Long 축제_식별자; + + @BeforeEach + void setUp() { + var 학교 = schoolRepository.save(SchoolFixture.builder().build()); + 축제_식별자 = festivalRepository.save(FestivalFixture.builder() + .startDate(_2077년_6월_15일) + .endDate(_2077년_6월_15일) + .school(학교) + .build()).getId(); + } + + @Test + void 빈_리스트가_반환된다() { + // when + var actual = adminStageV1QueryService.findAllByFestivalId(축제_식별자); + + // then + assertThat(actual).isEmpty(); + } + } + + /** + * 6월 15일 ~ 6월 17일까지 진행되는 축제

6월 15일 공연, 6월 16일 공연이 있다.

+ *

+ * 6월 15일 공연에는 아티스트A, 아티스트B가 참여한다.

6월 16일 공연에는 아티스트C가 참여한다. + */ + @Nested + class 축제에_공연이_있으면 { + + Long 아티스트A_식별자; + Long 아티스트B_식별자; + Long 아티스트C_식별자; + Festival 축제; + Long _6월_15일_공연_식별자; + Long _6월_16일_공연_식별자; + + @BeforeEach + void setUp() { + 아티스트A_식별자 = createArtist("아티스트A").getId(); + 아티스트B_식별자 = createArtist("아티스트B").getId(); + 아티스트C_식별자 = createArtist("아티스트C").getId(); + var 학교 = schoolRepository.save(SchoolFixture.builder().build()); + 축제 = festivalRepository.save(FestivalFixture.builder() + .startDate(_2077년_6월_15일) + .endDate(_2077년_6월_17일) + .school(학교) + .build()); + _6월_15일_공연_식별자 = createStage(축제, _2077년_6월_15일, List.of(아티스트A_식별자, 아티스트B_식별자)).getId(); + _6월_16일_공연_식별자 = createStage(축제, _2077년_6월_16일, List.of(아티스트C_식별자)).getId(); + } + + @Test + void 공연의_시작_순서대로_정렬된다() { + // when + var actual = adminStageV1QueryService.findAllByFestivalId(축제.getId()); + + // then + assertThat(actual) + .map(AdminStageV1Response::id) + .containsExactly(_6월_15일_공연_식별자, _6월_16일_공연_식별자); + } + + @Test + void 해당_일자의_공연에_참여하는_아티스트_목록을_조회할_수_있다() { + // when + var stageIdToArtists = adminStageV1QueryService.findAllByFestivalId(축제.getId()).stream() + .collect(toMap(AdminStageV1Response::id, AdminStageV1Response::artists)); + + // then + assertSoftly(softly -> { + softly.assertThat(stageIdToArtists.get(_6월_15일_공연_식별자)) + .map(AdminStageArtistV1Response::id) + .containsExactlyInAnyOrder(아티스트A_식별자, 아티스트B_식별자); + + softly.assertThat(stageIdToArtists.get(_6월_16일_공연_식별자)) + .map(AdminStageArtistV1Response::id) + .containsExactlyInAnyOrder(아티스트C_식별자); + }); + } + } + } + + private Artist createArtist(String artistName) { + return artistRepository.save(ArtistFixture.builder() + .name(artistName) + .build() + ); + } + + private Stage createStage(Festival festival, LocalDate localDate, List artistIds) { + var 공연 = stageRepository.save(StageFixture.builder() + .festival(festival) + .startTime(localDate.atTime(18, 0)) + .build() + ); + for (Long artistId : artistIds) { + stageArtistRepository.save(StageArtistFixture.builder(공연.getId(), artistId).build()); + } + return 공연; + } + + @Nested + class findById { + + @Nested + class 식별자에_해당하는_공연이_없으면 { + + @Test + void 예외가_발생한다() { + // when & then + assertThatThrownBy(() -> adminStageV1QueryService.findById(4885L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.STAGE_NOT_FOUND.getMessage()); + } + } + + @Nested + class 식별자에_해당하는_공연이_있으면 { + + Artist 아티스트A; + Artist 아티스트B; + Artist 아티스트C; + Stage 공연; + + @BeforeEach + void setUp() { + var 학교 = schoolRepository.save(SchoolFixture.builder().build()); + var 축제 = festivalRepository.save(FestivalFixture.builder() + .startDate(_2077년_6월_15일) + .endDate(_2077년_6월_15일) + .school(학교) + .build() + ); + 아티스트A = createArtist("아티스트A"); + 아티스트B = createArtist("아티스트B"); + 아티스트C = createArtist("아티스트C"); + 공연 = createStage( + 축제, + _2077년_6월_15일, + List.of(아티스트A.getId(), 아티스트B.getId(), 아티스트C.getId()) + ); + } + + @Test + void 공연의_정보가_정확하게_조회되어야_한다() { + // when + var actual = adminStageV1QueryService.findById(공연.getId()); + + assertSoftly(softly -> { + softly.assertThat(actual.id()).isEqualTo(공연.getId()); + softly.assertThat(actual.startDateTime()).isEqualTo(공연.getStartTime()); + softly.assertThat(actual.ticketOpenTime()).isEqualTo(공연.getTicketOpenTime()); + }); + } + + @Test + void 공연의_아티스트_목록이_조회되어야_한다() { + // when + var actual = adminStageV1QueryService.findById(공연.getId()); + + // then + assertThat(actual.artists()) + .map(AdminStageArtistV1Response::name) + .containsExactlyInAnyOrder(아티스트A.getName(), 아티스트B.getName(), 아티스트C.getName()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/domain/AdminTest.java b/backend/src/test/java/com/festago/admin/domain/AdminTest.java new file mode 100644 index 000000000..46492486b --- /dev/null +++ b/backend/src/test/java/com/festago/admin/domain/AdminTest.java @@ -0,0 +1,64 @@ +package com.festago.admin.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.festago.common.exception.ValidException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminTest { + + @Test + void 어드민_생성_성공() { + // given + Admin admin = new Admin("admin", "password"); + + // when & then + assertThat(admin.getUsername()).isEqualTo("admin"); + assertThat(admin.getPassword()).isEqualTo("password"); + } + + @Test + void username이_4글자_미만이면_예외() { + // given + String username = "1".repeat(3); + + // when & then + assertThatThrownBy(() -> new Admin(username, "password")) + .isInstanceOf(ValidException.class); + } + + @Test + void username이_20글자_초과하면_예외() { + // given + String username = "1".repeat(21); + + // when & then + assertThatThrownBy(() -> new Admin(username, "password")) + .isInstanceOf(ValidException.class); + } + + @Test + void password가_4글자_미만이면_예외() { + // given + String password = "1".repeat(3); + + // when & then + assertThatThrownBy(() -> new Admin("admin", password)) + .isInstanceOf(ValidException.class); + } + + @Test + void password가_255글자_초과하면_예외() { + // given + String password = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> new Admin("admin", password)) + .isInstanceOf(ValidException.class); + } +} diff --git a/backend/src/test/java/com/festago/admin/infrastructure/ActuatorProxyClientTest.java b/backend/src/test/java/com/festago/admin/infrastructure/ActuatorProxyClientTest.java new file mode 100644 index 000000000..e2ea0bcbe --- /dev/null +++ b/backend/src/test/java/com/festago/admin/infrastructure/ActuatorProxyClientTest.java @@ -0,0 +1,74 @@ +package com.festago.admin.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.NotFoundException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.response.MockRestResponseCreators; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@RestClientTest(ActuatorProxyClient.class) +class ActuatorProxyClientTest { + + private static final String URI = "http://localhost:8090/actuator/health"; + + @Autowired + ActuatorProxyClient actuatorProxyClient; + + @Autowired + MockRestServiceServer mockServer; + + @Test + void 상태코드가_4xx이면_NotFound_예외() { + // given + mockServer.expect(requestTo(URI)) + .andRespond(MockRestResponseCreators.withBadRequest() + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatThrownBy(() -> actuatorProxyClient.request("health")) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.ACTUATOR_NOT_FOUND.getMessage()); + } + + @Test + void 상태코드가_5xx이면_InternalServer_예외() { + // given + mockServer.expect(requestTo(URI)) + .andRespond(MockRestResponseCreators.withStatus(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatThrownBy(() -> actuatorProxyClient.request("health")) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.INTERNAL_SERVER_ERROR.getMessage()); + } + + @Test + void 성공() throws JsonProcessingException { + // given + mockServer.expect(requestTo(URI)) + .andRespond(MockRestResponseCreators.withSuccess() + .body("data") + .contentType(MediaType.APPLICATION_JSON)); + + // when + var response = actuatorProxyClient.request("health"); + + // then + assertThat(response.getBody()).isEqualTo("data"); + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/AdminActuatorControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/AdminActuatorControllerTest.java new file mode 100644 index 000000000..3625041e2 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/AdminActuatorControllerTest.java @@ -0,0 +1,69 @@ +package com.festago.admin.presentation; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.admin.application.AdminActuatorProxyService; +import com.festago.auth.domain.Role; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminActuatorControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + AdminActuatorProxyService adminActuatorProxyService; + + @Nested + class 엑추에이터_조회 { + + final String uri = "/admin/api/actuator/{path}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, "health") + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, "health")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, "health") + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java new file mode 100644 index 000000000..852df9de5 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminArtistV1ControllerTest.java @@ -0,0 +1,283 @@ +package com.festago.admin.presentation.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.application.AdminArtistV1QueryService; +import com.festago.admin.dto.artist.AdminArtistV1Response; +import com.festago.admin.dto.artist.ArtistV1CreateRequest; +import com.festago.admin.dto.artist.ArtistV1UpdateRequest; +import com.festago.artist.application.ArtistCommandService; +import com.festago.artist.dto.command.ArtistCreateCommand; +import com.festago.auth.domain.Role; +import com.festago.common.querydsl.SearchCondition; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminArtistV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + @Autowired + ObjectMapper objectMapper; + @Autowired + AdminArtistV1QueryService adminArtistV1QueryService; + @Autowired + ArtistCommandService artistCommandService; + + @Nested + class 아티스트_생성 { + + final String uri = "/admin/api/v1/artists"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_201_응답과_Location_헤더에_식별자가_반환된다() throws Exception { + // given + ArtistV1CreateRequest request = new ArtistV1CreateRequest("윤서연", "https://image.com/image.png", + "https://image.com/image.png"); + given(artistCommandService.save(any(ArtistCreateCommand.class))) + .willReturn(1L); + + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, uri + "/1")); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 아티스트_수정 { + + final String uri = "/admin/api/v1/artists/{artistId}"; + + @Nested + @DisplayName("PUT " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + ArtistV1UpdateRequest request = new ArtistV1UpdateRequest("윤하", "https://image.com/image.png", + "https://image.com/image.png"); + + // when & then + mockMvc.perform(put(uri, 1L) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(put(uri, 1L)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(put(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 아티스트_삭제 { + + final String uri = "/admin/api/v1/artists/{artistId}"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_204_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L) + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNoContent()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 아티스트_단일_조회 { + + final String uri = "/admin/api/v1/artists/{artistId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var expected = new AdminArtistV1Response( + 1L, + "윤하", + "https://image.com/image1.png", + "https://image.com/background1.png", + LocalDateTime.now(), + LocalDateTime.now() + ); + given(adminArtistV1QueryService.findById(anyLong())) + .willReturn(expected); + + // when & then + mockMvc.perform(get(uri, 1L) + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, 1L)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 아티스트_전체_조회 { + + final String uri = "/admin/api/v1/artists"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var expected = List.of( + new AdminArtistV1Response( + 1L, + "윤하", + "https://image.com/image1.png", + "https://image.com/background1.png", + LocalDateTime.now(), + LocalDateTime.now() + ) + ); + given(adminArtistV1QueryService.findAll(any(SearchCondition.class))) + .willReturn(new PageImpl<>(expected)); + + // when & then + mockMvc.perform(get(uri) + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java new file mode 100644 index 000000000..f5bb98c64 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminFestivalV1ControllerTest.java @@ -0,0 +1,390 @@ +package com.festago.admin.presentation.v1; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.application.AdminFestivalV1QueryService; +import com.festago.admin.application.AdminStageV1QueryService; +import com.festago.admin.dto.festival.AdminFestivalDetailV1Response; +import com.festago.admin.dto.festival.AdminFestivalV1Response; +import com.festago.admin.dto.festival.FestivalV1CreateRequest; +import com.festago.admin.dto.festival.FestivalV1UpdateRequest; +import com.festago.admin.dto.stage.AdminStageArtistV1Response; +import com.festago.admin.dto.stage.AdminStageV1Response; +import com.festago.auth.domain.Role; +import com.festago.common.querydsl.SearchCondition; +import com.festago.festival.application.command.FestivalCommandFacadeService; +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminFestivalV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + FestivalCommandFacadeService festivalCommandFacadeService; + + @Autowired + AdminFestivalV1QueryService adminFestivalV1QueryService; + + @Autowired + AdminStageV1QueryService adminStageV1QueryService; + + @Nested + class 축제_생성 { + + final String uri = "/admin/api/v1/festivals"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + String name = "테코대학교 축제"; + LocalDate startDate = LocalDate.parse("2024-01-31"); + LocalDate endDate = LocalDate.parse("2024-02-01"); + String posterImageUrl = "https://image.com/image.png"; + FestivalV1CreateRequest request = new FestivalV1CreateRequest(name, startDate, endDate, posterImageUrl, 1L); + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_201_응답과_Location_헤더에_식별자가_반환된다() throws Exception { + // given + given(festivalCommandFacadeService.createFestival(any(FestivalCreateCommand.class))) + .willReturn(1L); + + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/admin/api/v1/festivals/1")); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 축제_수정 { + + final String uri = "/admin/api/v1/festivals/{festivalId}"; + + @Nested + @DisplayName("PATCH " + uri) + class 올바른_주소로 { + + String name = "테코대학교 축제"; + LocalDate startDate = LocalDate.parse("2024-01-31"); + LocalDate endDate = LocalDate.parse("2024-02-01"); + String posterImageUrl = "https://image.com/image.png"; + FestivalV1UpdateRequest request = new FestivalV1UpdateRequest(name, startDate, endDate, posterImageUrl); + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1L) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1L)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 축제_삭제 { + + final String uri = "/admin/api/v1/festivals/{festivalId}"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_204_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNoContent()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 모든_축제_정보_조회 { + + final String uri = "/admin/api/v1/festivals"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_하면_200_응답과_학교_정보_목록이_반환된다() throws Exception { + // given + var expected = List.of( + new AdminFestivalV1Response(1L, "테코대학교 축제", "테코대학교", LocalDate.now(), LocalDate.now(), 0) + ); + given(adminFestivalV1QueryService.findAll(any(SearchCondition.class))) + .willReturn(new PageImpl<>(expected)); + + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()").value(1)); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 축제_상세_조회 { + + final String uri = "/admin/api/v1/festivals/{festivalId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + private final Long festivalId = 1L; + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_하면_200_응답과_축제_상세_정보가_반환된다() throws Exception { + // given + var expected = getAdminFestivalDetailV1Response(); + given(adminFestivalV1QueryService.findDetail(anyLong())) + .willReturn(expected); + + // when & then + String content = mockMvc.perform(get(uri, festivalId) + .contentType(MediaType.APPLICATION_JSON) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(objectMapper.readValue(content, AdminFestivalDetailV1Response.class)) + .isEqualTo(expected); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, festivalId)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, festivalId) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + + private AdminFestivalDetailV1Response getAdminFestivalDetailV1Response() { + String name = "테코대학교 축제"; + Long schoolId = 2L; + String schoolName = "테코대학교"; + LocalDate startDate = LocalDate.parse("2077-06-29"); + LocalDate endDate = startDate.plusDays(2); + String posterImageUrl = "https://image.com/image.png"; + LocalDateTime createdAt = startDate.atStartOfDay(); + LocalDateTime updatedAt = startDate.atStartOfDay(); + return new AdminFestivalDetailV1Response( + festivalId, + name, + schoolId, + schoolName, + startDate, + endDate, + posterImageUrl, + createdAt, + updatedAt + ); + } + } + } + + @Nested + class 축제_공연_목록_조회 { + + final String uri = "/admin/api/v1/festivals/{festivalId}/stages"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + private final Long festivalId = 1L; + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_하면_200_응답과_축제에_속한_공연_목록_정보가_반환된다() throws Exception { + var expected = getAdminStageV1Responses(); + given(adminStageV1QueryService.findAllByFestivalId(festivalId)) + .willReturn(expected); + + mockMvc.perform(get(uri, festivalId) + .contentType(MediaType.APPLICATION_JSON) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isOk()); + } + + private List getAdminStageV1Responses() { + return List.of( + new AdminStageV1Response( + 1L, + LocalDateTime.now(), + LocalDateTime.now().minusWeeks(1), + List.of( + new AdminStageArtistV1Response( + 1L, + "에픽하이" + ), + new AdminStageArtistV1Response( + 2L, + "아이유" + ) + ), + LocalDateTime.now(), + LocalDateTime.now() + ), + new AdminStageV1Response( + 2L, + LocalDateTime.now().plusDays(1), + LocalDateTime.now().minusWeeks(1), + List.of( + new AdminStageArtistV1Response( + 3L, + "푸우회장" + ) + ), + LocalDateTime.now(), + LocalDateTime.now() + ) + ); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, festivalId)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, festivalId) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminMockDataV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminMockDataV1ControllerTest.java new file mode 100644 index 000000000..f6eb8ef10 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminMockDataV1ControllerTest.java @@ -0,0 +1,73 @@ +package com.festago.admin.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.auth.domain.Role; +import com.festago.mock.application.MockDataService; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminMockDataV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + MockDataService mockDataService; + + @Nested + class Mock_축제_생성 { + + final String uri = "/admin/api/v1/mock-data/festivals"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminQueryInfoRenewalV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminQueryInfoRenewalV1ControllerTest.java new file mode 100644 index 000000000..6f9c8cc9f --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminQueryInfoRenewalV1ControllerTest.java @@ -0,0 +1,117 @@ +package com.festago.admin.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.application.AdminQueryInfoRenewalService; +import com.festago.admin.dto.queryinfo.QueryInfoRenewalFestivalPeriodV1Request; +import com.festago.auth.domain.Role; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminQueryInfoRenewalV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + AdminQueryInfoRenewalService adminQueryInfoRenewalService; + + @Nested + class QueryInfo_재갱신_by_festivalId { + + final String uri = "/admin/api/v1/query-info/renewal/festival-id/{festivalId}"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + private long festivalId = 1L; + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri, festivalId) + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri, festivalId)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri, festivalId) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class QueryInfo_재갱신_by_festival_startDate_period { + + final String uri = "/admin/api/v1/query-info/renewal/festival-period"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + var request = new QueryInfoRenewalFestivalPeriodV1Request(LocalDate.now(), LocalDate.now()); + // when & then + mockMvc.perform(post(uri) + .content(objectMapper.writeValueAsString(request)) + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java new file mode 100644 index 000000000..3b29b51a0 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSchoolV1ControllerTest.java @@ -0,0 +1,275 @@ +package com.festago.admin.presentation.v1; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.application.AdminSchoolV1QueryService; +import com.festago.admin.dto.school.AdminSchoolV1Response; +import com.festago.admin.dto.school.SchoolV1CreateRequest; +import com.festago.admin.dto.school.SchoolV1UpdateRequest; +import com.festago.auth.domain.Role; +import com.festago.common.querydsl.SearchCondition; +import com.festago.school.application.SchoolCommandService; +import com.festago.school.application.SchoolDeleteService; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.dto.command.SchoolCreateCommand; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminSchoolV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + SchoolCommandService schoolCommandService; + + @Autowired + SchoolDeleteService schoolDeleteService; + + @Autowired + AdminSchoolV1QueryService adminSchoolV1QueryService; + + @Nested + class 학교_생성 { + + final String uri = "/admin/api/v1/schools"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_201_응답과_Location_헤더에_식별자가_반환된다() throws Exception { + // given + var request = SchoolV1CreateRequest.builder() + .name("테코대학교") + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .logoUrl("https://image.com/logo.png") + .backgroundImageUrl("https://image.com/backgroundImage.png") + .build(); + given(schoolCommandService.createSchool(any(SchoolCreateCommand.class))) + .willReturn(1L); + + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/api/v1/schools/1")); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 학교_수정 { + + final String uri = "/admin/api/v1/schools/{schoolId}"; + + @Nested + @DisplayName("PATCH " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + var request = SchoolV1UpdateRequest.builder() + .name("테코대학교") + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .logoUrl("https://image.com/logo.png") + .backgroundImageUrl("https://image.com/backgroundImage.png") + .build(); + + // when & then + mockMvc.perform(patch(uri, 1L) + .cookie(new Cookie("token", "Bearer token")) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1L)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 학교_삭제 { + + final String uri = "/admin/api/v1/schools/{schoolId}"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_204_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L) + .cookie(new Cookie("token", "Bearer token")) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1L) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 모든_학교_정보_조회 { + + final String uri = "/admin/api/v1/schools"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_하면_200_응답과_학교_정보_목록이_반환된다() throws Exception { + // given + var expected = List.of( + new AdminSchoolV1Response( + 1L, + "teco.ac.kr", + "테코대학교", + SchoolRegion.서울, + "https://image.com/logo.png", + "https://image.com/backgroundImage.png", + LocalDateTime.now(), + LocalDateTime.now() + ) + ); + given(adminSchoolV1QueryService.findAll(any(SearchCondition.class))) + .willReturn(new PageImpl<>(expected)); + + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new Cookie("token", "Bearer token"))) + .andExpect(status().isOk()); + } + } + } + + @Nested + class 단일_학교_정보_조회 { + + final String uri = "/admin/api/v1/schools/{schoolId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_하면_200_응답과_학교_정보가_반환된다() throws Exception { + // given + var expected = new AdminSchoolV1Response( + 1L, + "teco.ac.kr", + "테코대학교", + SchoolRegion.서울, + "https://image.com/logo.png", + "https://image.com/backgroundImage.png", + LocalDateTime.now(), + LocalDateTime.now() + ); + given(adminSchoolV1QueryService.findById(anyLong())) + .willReturn(expected); + + // when & then + String content = mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new Cookie("token", "Bearer token"))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + var actual = objectMapper.readValue(content, AdminSchoolV1Response.class); + + assertThat(actual).isEqualTo(expected); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminSocialMediaV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSocialMediaV1ControllerTest.java new file mode 100644 index 000000000..586600844 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminSocialMediaV1ControllerTest.java @@ -0,0 +1,294 @@ +package com.festago.admin.presentation.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.application.AdminSocialMediaV1QueryService; +import com.festago.admin.dto.socialmedia.AdminSocialMediaV1Response; +import com.festago.admin.dto.socialmedia.SocialMediaCreateV1Request; +import com.festago.admin.dto.socialmedia.SocialMediaUpdateV1Request; +import com.festago.auth.domain.Role; +import com.festago.socialmedia.application.SocialMediaCommandService; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.dto.command.SocialMediaCreateCommand; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminSocialMediaV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + SocialMediaCommandService socialMediaCommandService; + + @Autowired + AdminSocialMediaV1QueryService adminSocialMediaV1QueryService; + + @Nested + class 소셜미디어_단건_조회 { + + final String uri = "/admin/api/v1/socialmedias/{socialMediaId}"; + Long socialMediaId = 1L; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + Long ownerId = 1L; + given(adminSocialMediaV1QueryService.findById(anyLong())) + .willReturn(new AdminSocialMediaV1Response( + socialMediaId, + ownerId, + OwnerType.SCHOOL, + SocialMediaType.INSTAGRAM, + "테코대학교 총학생회 인스타그램", + "https://image.com/logo.png", + "htps://instagram.com/tecodaehak" + )); + + // when & then + mockMvc.perform(get(uri, socialMediaId) + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, socialMediaId)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, socialMediaId) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 소셜미디어_목록_조회 { + + final String uri = "/admin/api/v1/socialmedias"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + Long socialMediaId = 1L; + Long ownerId = 1L; + given(adminSocialMediaV1QueryService.findByOwnerIdAndOwnerType(anyLong(), any(OwnerType.class))) + .willReturn(List.of( + new AdminSocialMediaV1Response( + socialMediaId, + ownerId, + OwnerType.SCHOOL, + SocialMediaType.INSTAGRAM, + "테코대학교 총학생회 인스타그램", + "https://image.com/logo.png", + "htps://instagram.com/tecodaehak" + ) + )); + + // when & then + mockMvc.perform(get(uri) + .param("ownerId", "1") + .param("ownerType", "SCHOOL") + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 소셜미디어_생성 { + + final String uri = "/admin/api/v1/socialmedias"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + Long socialMediaId = 1L; + var request = SocialMediaCreateV1Request.builder() + .ownerId(1L) + .ownerType(OwnerType.SCHOOL) + .socialMediaType(SocialMediaType.INSTAGRAM) + .url("https://instagram.com/tecodaehak") + .logoUrl("https://image.com/logo.png") + .name("테코대학교 총학생회 인스타그램") + .build(); + given(socialMediaCommandService.createSocialMedia(any(SocialMediaCreateCommand.class))) + .willReturn(socialMediaId); + + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 소셜미디어_수정 { + + final String uri = "/admin/api/v1/socialmedias/{socialMediaId}"; + Long socialMediaId = 1L; + + @Nested + @DisplayName("PATCH " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + var request = SocialMediaUpdateV1Request.builder() + .url("https://instagram.com/tecodaehak") + .logoUrl("https://image.com/logo.png") + .name("테코대학교 총학생회 인스타그램") + .build(); + + // when & then + mockMvc.perform(patch(uri, socialMediaId) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, socialMediaId)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, socialMediaId) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 소셜미디어_삭제 { + + final String uri = "/admin/api/v1/socialmedias/{socialMediaId}"; + Long socialMediaId = 1L; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_204_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, socialMediaId) + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, socialMediaId)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, socialMediaId) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminStageV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminStageV1ControllerTest.java new file mode 100644 index 000000000..e447d5714 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminStageV1ControllerTest.java @@ -0,0 +1,216 @@ +package com.festago.admin.presentation.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.dto.stage.StageV1CreateRequest; +import com.festago.admin.dto.stage.StageV1UpdateRequest; +import com.festago.auth.domain.Role; +import com.festago.stage.application.command.StageCommandFacadeService; +import com.festago.stage.dto.command.StageCreateCommand; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminStageV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + StageCommandFacadeService stageCommandFacadeService; + + @Nested + class 공연_단건_조회 { + + final String uri = "/admin/api/v1/stages/{stageId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + Long stageId = 1L; + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + mockMvc.perform(get(uri, stageId) + .cookie(TOKEN_COOKIE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, 1)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri, 1) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 공연_생성 { + + final String uri = "/admin/api/v1/stages"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + Long festivalId = 1L; + LocalDateTime startTime = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime ticketOpenTime = LocalDateTime.parse("2077-06-23T00:00:00"); + List artistIds = List.of(1L, 2L, 3L); + StageV1CreateRequest request = new StageV1CreateRequest(festivalId, startTime, ticketOpenTime, + artistIds); + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_201_응답과_Location_헤더에_식별자가_반환된다() throws Exception { + // given + given(stageCommandFacadeService.createStage(any(StageCreateCommand.class))) + .willReturn(1L); + + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, "/admin/api/v1/stages/1")); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 공연_수정 { + + final String uri = "/admin/api/v1/stages/{stageId}"; + + @Nested + @DisplayName("PATCH " + uri) + class 올바른_주소로 { + + LocalDateTime startTime = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime ticketOpenTime = LocalDateTime.parse("2077-06-23T00:00:00"); + List artistIds = List.of(1L, 2L, 3L); + StageV1UpdateRequest request = new StageV1UpdateRequest(startTime, ticketOpenTime, artistIds); + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(patch(uri, 1) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 공연_삭제 { + + final String uri = "/admin/api/v1/stages/{stageId}"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_204_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNoContent()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri, 1) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminUploadFileDeleteV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminUploadFileDeleteV1ControllerTest.java new file mode 100644 index 000000000..e92023eb8 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminUploadFileDeleteV1ControllerTest.java @@ -0,0 +1,114 @@ +package com.festago.admin.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.admin.dto.upload.AdminDeleteAbandonedPeriodUploadFileV1Request; +import com.festago.auth.domain.Role; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminUploadFileDeleteV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Nested + class ABANDONED_상태와_기간에_포함되는_파일_삭제 { + + final String uri = "/admin/api/v1/upload/delete/abandoned-period"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + LocalDateTime now = LocalDateTime.now(); + var request = new AdminDeleteAbandonedPeriodUploadFileV1Request(now, now); + + // when & then + mockMvc.perform(delete(uri) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 오래된_UPLOADED_상태_파일_삭제 { + + final String uri = "/admin/api/v1/upload/delete/old-uploaded"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + // when & then + mockMvc.perform(delete(uri) + .contentType(MediaType.APPLICATION_JSON) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/presentation/v1/AdminUploadImageV1ControllerTest.java b/backend/src/test/java/com/festago/admin/presentation/v1/AdminUploadImageV1ControllerTest.java new file mode 100644 index 000000000..a230a1d26 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/presentation/v1/AdminUploadImageV1ControllerTest.java @@ -0,0 +1,89 @@ +package com.festago.admin.presentation.v1; + +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.auth.domain.Role; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import com.festago.upload.application.ImageFileUploadService; +import com.festago.upload.dto.FileUploadResult; +import jakarta.servlet.http.Cookie; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminUploadImageV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + ImageFileUploadService imageFileUploadService; + + @Nested + class 이미지_업로드 { + + final String uri = "/admin/api/v1/upload/images"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + UUID fileId = UUID.randomUUID(); + URI uploadUri = URI.create("https://festago.com/" + fileId + ".png"); + given(imageFileUploadService.upload(any(), any(), any())) + .willReturn(new FileUploadResult(fileId, uploadUri)); + MockMultipartFile multipartFile = new MockMultipartFile( + "image", "image.png", "png", "data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + mockMvc.perform(multipart(uri) + .file(multipartFile) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/admin/repository/AdminFestivalIdResolverQueryDslRepositoryTest.java b/backend/src/test/java/com/festago/admin/repository/AdminFestivalIdResolverQueryDslRepositoryTest.java new file mode 100644 index 000000000..1246c09d4 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/repository/AdminFestivalIdResolverQueryDslRepositoryTest.java @@ -0,0 +1,67 @@ +package com.festago.admin.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminFestivalIdResolverQueryDslRepositoryTest extends ApplicationIntegrationTest { + + @Autowired + AdminFestivalIdResolverQueryDslRepository adminFestivalIdResolverQueryDslRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + School 테코대학교; + + LocalDate _6월_12일 = LocalDate.parse("2077-06-12"); + LocalDate _6월_13일 = LocalDate.parse("2077-06-13"); + LocalDate _6월_14일 = LocalDate.parse("2077-06-14"); + LocalDate _6월_15일 = LocalDate.parse("2077-06-15"); + + @BeforeEach + void setUp() { + 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + } + + @Nested + class findFestivalIdsByWithinDates { + + @Test + void 축제의_시작일에_포함되는_축제의_식별자_목록을_반환한다() { + // given + Festival _6월_12일_축제 = festivalRepository.save( + FestivalFixture.builder().startDate(_6월_12일).school(테코대학교).build()); + Festival _6월_13일_축제 = festivalRepository.save( + FestivalFixture.builder().startDate(_6월_13일).school(테코대학교).build()); + Festival _6월_14일_축제 = festivalRepository.save( + FestivalFixture.builder().startDate(_6월_14일).school(테코대학교).build()); + Festival _6월_15일_축제 = festivalRepository.save( + FestivalFixture.builder().startDate(_6월_15일).school(테코대학교).build()); + + // when + var actual = adminFestivalIdResolverQueryDslRepository.findFestivalIdsByStartDatePeriod(_6월_13일, _6월_14일); + + // then + assertThat(actual).containsExactlyInAnyOrder(_6월_13일_축제.getId(), _6월_14일_축제.getId()); + } + } +} diff --git a/backend/src/test/java/com/festago/admin/repository/AdminStageIdResolverQueryDslRepositoryTest.java b/backend/src/test/java/com/festago/admin/repository/AdminStageIdResolverQueryDslRepositoryTest.java new file mode 100644 index 000000000..44cff8b19 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/repository/AdminStageIdResolverQueryDslRepositoryTest.java @@ -0,0 +1,94 @@ +package com.festago.admin.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminStageIdResolverQueryDslRepositoryTest extends ApplicationIntegrationTest { + + @Autowired + AdminStageIdResolverQueryDslRepository adminStageIdResolverQueryDslRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + SchoolRepository schoolRepository; + + School 테코대학교; + + @BeforeEach + void setUp() { + 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + } + + @Nested + class findStageIdsByFestivalId { + + @Test + void 축제_식별자로_공연의_식별자를_모두_조회한다() { + // given + Festival festival = festivalRepository.save(FestivalFixture.builder().school(테코대학교).build()); + List expect = IntStream.rangeClosed(1, 3) + .mapToObj(i -> stageRepository.save(StageFixture.builder().festival(festival).build())) + .map(Stage::getId) + .toList(); + + // when + List actual = adminStageIdResolverQueryDslRepository.findStageIdsByFestivalId(festival.getId()); + + // then + assertThat(actual).containsExactlyInAnyOrderElementsOf(expect); + } + } + + @Nested + class findStageIdsByFestivalIdIn { + + @Test + void 축제_식별자_목록으로_공연의_식별자를_모두_조회한다() { + // given + List festivals = IntStream.rangeClosed(1, 2) + .mapToObj(i -> festivalRepository.save(FestivalFixture.builder().school(테코대학교).build())) + .toList(); + List expect = festivals.stream() + .map(festival -> IntStream.rangeClosed(1, 3) + .mapToObj(j -> stageRepository.save(StageFixture.builder().festival(festival).build())) + .map(Stage::getId) + .toList()) + .flatMap(List::stream) + .toList(); + + // when + List festivalIds = festivals.stream() + .map(Festival::getId) + .toList(); + List actual = adminStageIdResolverQueryDslRepository.findStageIdsByFestivalIdIn(festivalIds); + + // then + assertThat(actual).containsExactlyInAnyOrderElementsOf(expect); + } + } +} diff --git a/backend/src/test/java/com/festago/admin/repository/MemoryAdminRepository.java b/backend/src/test/java/com/festago/admin/repository/MemoryAdminRepository.java new file mode 100644 index 000000000..00d48a893 --- /dev/null +++ b/backend/src/test/java/com/festago/admin/repository/MemoryAdminRepository.java @@ -0,0 +1,22 @@ +package com.festago.admin.repository; + +import com.festago.admin.domain.Admin; +import com.festago.support.AbstractMemoryRepository; +import java.util.Objects; +import java.util.Optional; + +public class MemoryAdminRepository extends AbstractMemoryRepository implements AdminRepository { + + @Override + public Optional findByUsername(String username) { + return memory.values().stream() + .filter(admin -> Objects.equals(admin.getUsername(), username)) + .findAny(); + } + + @Override + public boolean existsByUsername(String username) { + return memory.values().stream() + .anyMatch(admin -> Objects.equals(admin.getUsername(), username)); + } +} diff --git a/backend/src/test/java/com/festago/application/FestivalServiceTest.java b/backend/src/test/java/com/festago/application/FestivalServiceTest.java deleted file mode 100644 index c8c5c3387..000000000 --- a/backend/src/test/java/com/festago/application/FestivalServiceTest.java +++ /dev/null @@ -1,174 +0,0 @@ -package com.festago.application; - -import static com.festago.common.exception.ErrorCode.FESTIVAL_NOT_FOUND; -import static com.festago.common.exception.ErrorCode.INVALID_FESTIVAL_START_DATE; -import static com.festago.common.exception.ErrorCode.SCHOOL_NOT_FOUND; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; - -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.NotFoundException; -import com.festago.festival.application.FestivalService; -import com.festago.festival.domain.Festival; -import com.festago.festival.dto.FestivalCreateRequest; -import com.festago.festival.dto.FestivalDetailResponse; -import com.festago.festival.dto.FestivalDetailStageResponse; -import com.festago.festival.dto.FestivalResponse; -import com.festago.festival.dto.FestivalsResponse; -import com.festago.festival.repository.FestivalRepository; -import com.festago.school.domain.School; -import com.festago.school.repository.SchoolRepository; -import com.festago.stage.domain.Stage; -import com.festago.stage.repository.StageRepository; -import com.festago.support.FestivalFixture; -import com.festago.support.SchoolFixture; -import com.festago.support.StageFixture; -import java.time.Clock; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class FestivalServiceTest { - - @Mock - FestivalRepository festivalRepository; - - @Mock - StageRepository stageRepository; - - @Spy - Clock clock = Clock.systemDefaultZone(); - - @Mock - SchoolRepository schoolRepository; - - @InjectMocks - FestivalService festivalService; - - @Test - void 모든_축제_조회() { - // given - Festival festival1 = FestivalFixture.festival().id(1L).build(); - Festival festival2 = FestivalFixture.festival().id(2L).build(); - given(festivalRepository.findAll()).willReturn(List.of(festival1, festival2)); - - // when - FestivalsResponse response = festivalService.findAll(); - - // then - List festivalIds = response.festivals().stream().map(FestivalResponse::id).toList(); - - assertThat(festivalIds).containsExactly(1L, 2L); - } - - @Nested - class 축제_생성 { - - @Test - void 학교가_없으면_예외() { - // given - LocalDate today = LocalDate.now(); - Long schoolId = 1L; - FestivalCreateRequest request = new FestivalCreateRequest("테코대학교", today, today, "http://image.png", 1L); - - given(schoolRepository.findById(schoolId)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> festivalService.create(request)) - .isInstanceOf(NotFoundException.class) - .hasMessage(SCHOOL_NOT_FOUND.getMessage()); - } - - @Test - void 축제_생성시_시작일자가_과거이면_예외() { - // given - LocalDate today = LocalDate.now(); - School school = SchoolFixture.school().build(); - FestivalCreateRequest request = new FestivalCreateRequest("테코대학교", today.minusDays(1), today, - "http://image.png", 1L); - given(schoolRepository.findById(anyLong())) - .willReturn(Optional.of(school)); - - // when & then - assertThatThrownBy(() -> festivalService.create(request)) - .isInstanceOf(BadRequestException.class) - .hasMessage(INVALID_FESTIVAL_START_DATE.getMessage()); - } - - @Test - void 성공() { - // given - LocalDate today = LocalDate.now(); - String name = "테코대학교"; - String thumbnail = "http://image.png"; - Long schoolId = 1L; - School school = SchoolFixture.school().id(schoolId).build(); - FestivalCreateRequest request = new FestivalCreateRequest(name, today, today, thumbnail, schoolId); - Festival festival = new Festival(1L, name, today, today, thumbnail, school); - FestivalResponse expected = new FestivalResponse(1L, 1L, name, today, today, thumbnail); - given(schoolRepository.findById(schoolId)) - .willReturn(Optional.of(school)); - given(festivalRepository.save(any())) - .willReturn(festival); - - // when - FestivalResponse actual = festivalService.create(request); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - } - - @Nested - class 축제_상세_조회 { - - @Test - void 축제가_없다면_예외() { - // given - Long festivalId = 1L; - given(festivalRepository.findById(festivalId)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> festivalService.findDetail(festivalId)).isInstanceOf(NotFoundException.class) - .hasMessage(FESTIVAL_NOT_FOUND.getMessage()); - } - - @Test - void 무대_시작시간순으로_정렬() { - // given - Long festivalId = 1L; - Festival festival = FestivalFixture.festival().id(festivalId).build(); - LocalDateTime now = LocalDateTime.now(); - Stage stage1 = StageFixture.stage().id(1L).startTime(now).festival(festival).build(); - Stage stage2 = StageFixture.stage().id(2L).startTime(now.plusDays(1)).festival(festival).build(); - - given(festivalRepository.findById(festivalId)).willReturn(Optional.of(festival)); - given(stageRepository.findAllDetailByFestivalId(festival.getId())).willReturn(List.of(stage2, stage1)); - - // when - FestivalDetailResponse response = festivalService.findDetail(festivalId); - - // then - List stageIds = response.stages().stream().map(FestivalDetailStageResponse::id).toList(); - assertThat(stageIds).containsExactly(1L, 2L); - } - } -} diff --git a/backend/src/test/java/com/festago/application/MemberTicketServiceTest.java b/backend/src/test/java/com/festago/application/MemberTicketServiceTest.java deleted file mode 100644 index 31f515d3f..000000000 --- a/backend/src/test/java/com/festago/application/MemberTicketServiceTest.java +++ /dev/null @@ -1,267 +0,0 @@ -package com.festago.application; - -import static com.festago.common.exception.ErrorCode.MEMBER_NOT_FOUND; -import static com.festago.common.exception.ErrorCode.MEMBER_TICKET_NOT_FOUND; -import static com.festago.common.exception.ErrorCode.NOT_MEMBER_TICKET_OWNER; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.anyLong; -import static org.mockito.BDDMockito.given; - -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.NotFoundException; -import com.festago.member.domain.Member; -import com.festago.member.repository.MemberRepository; -import com.festago.stage.domain.Stage; -import com.festago.support.MemberFixture; -import com.festago.support.MemberTicketFixture; -import com.festago.support.StageFixture; -import com.festago.ticketing.application.MemberTicketService; -import com.festago.ticketing.domain.MemberTicket; -import com.festago.ticketing.dto.MemberTicketResponse; -import com.festago.ticketing.dto.MemberTicketsResponse; -import com.festago.ticketing.repository.MemberTicketRepository; -import java.time.Clock; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; - -@ExtendWith(MockitoExtension.class) -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class MemberTicketServiceTest { - - @Mock - MemberTicketRepository memberTicketRepository; - - @Mock - MemberRepository memberRepository; - - @Spy - Clock clock = Clock.systemDefaultZone(); - - @InjectMocks - MemberTicketService memberTicketService; - - @Nested - class 사용자의_멤버티켓_전체_조회 { - - @Test - void 멤버가_없으면_예외() { - // given - Long memberId = 1L; - - given(memberRepository.findById(memberId)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberTicketService.findAll(memberId, PageRequest.ofSize(1))) - .isInstanceOf(NotFoundException.class) - .hasMessage(MEMBER_NOT_FOUND.getMessage()); - } - } - - @Nested - class 현재_멤버티켓_조회 { - - @Test - void 멤버가_없으면_예외() { - // given - Long memberId = 1L; - - given(memberRepository.findById(memberId)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberTicketService.findCurrent(memberId, Pageable.ofSize(10))) - .isInstanceOf(NotFoundException.class) - .hasMessage(MEMBER_NOT_FOUND.getMessage()); - } - - @Test - void 입장시간이_24시간_지난_티켓은_조회되지_않는다() { - // given - Long memberId = 1L; - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .entryTime(LocalDateTime.now().minusHours(25)) - .build(); - - given(memberTicketRepository.findAllByOwnerId(anyLong(), any(Pageable.class))) - .willReturn(List.of(memberTicket)); - given(memberRepository.findById(memberId)) - .willReturn(Optional.of(new Member(memberId))); - - // when - MemberTicketsResponse response = memberTicketService.findCurrent(memberId, Pageable.ofSize(100)); - - // then - assertThat(response.memberTickets()).isEmpty(); - } - - @Test - void 활성화된_티켓이_먼저_조회된다() { - // given - Long memberId = 1L; - MemberTicket pendingMemberTicket = MemberTicketFixture.memberTicket() - .id(1L) - .entryTime(LocalDateTime.now().plusHours(1)) - .build(); - MemberTicket activateMemberTicket = MemberTicketFixture.memberTicket() - .id(2L) - .entryTime(LocalDateTime.now().minusHours(1)) - .build(); - - given(memberTicketRepository.findAllByOwnerId(eq(memberId), any(Pageable.class))) - .willReturn(List.of(pendingMemberTicket, activateMemberTicket)); - given(memberRepository.findById(memberId)) - .willReturn(Optional.of(new Member(memberId))); - - // when - MemberTicketsResponse response = memberTicketService.findCurrent(memberId, Pageable.ofSize(100)); - - // then - List memberTicketIds = response.memberTickets().stream() - .map(MemberTicketResponse::id) - .toList(); - assertThat(memberTicketIds).containsExactly(2L, 1L); - } - - @Test - void 활성화_및_비활성화_내에서는_현재시간과_가까운순으로_정렬되어_조회된다() { - // given - Long memberId = 1L; - MemberTicket pendingMemberTicket1 = MemberTicketFixture.memberTicket() - .id(1L) - .entryTime(LocalDateTime.now().plusHours(1)) - .build(); - MemberTicket pendingMemberTicket2 = MemberTicketFixture.memberTicket() - .id(2L) - .entryTime(LocalDateTime.now().plusHours(2)) - .build(); - MemberTicket activateMemberTicket1 = MemberTicketFixture.memberTicket() - .id(3L) - .entryTime(LocalDateTime.now().minusHours(2)) - .build(); - MemberTicket activateMemberTicket2 = MemberTicketFixture.memberTicket() - .id(4L) - .entryTime(LocalDateTime.now().minusHours(1)) - .build(); - - given(memberTicketRepository.findAllByOwnerId(eq(memberId), any(Pageable.class))) - .willReturn( - List.of(pendingMemberTicket1, pendingMemberTicket2, activateMemberTicket1, activateMemberTicket2)); - given(memberRepository.findById(memberId)) - .willReturn(Optional.of(new Member(memberId))); - - // when - MemberTicketsResponse response = memberTicketService.findCurrent(memberId, Pageable.ofSize(100)); - - // then - List memberTicketIds = response.memberTickets().stream() - .map(MemberTicketResponse::id) - .toList(); - assertThat(memberTicketIds).containsExactly(4L, 3L, 1L, 2L); - } - } - - @Nested - class 멤버_티켓_아이디로_단건_조회 { - - @Test - void 멤버가_없으면_예외() { - // given - Long memberId = 1L; - - given(memberRepository.findById(memberId)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberTicketService.findById(memberId, 1L)) - .isInstanceOf(NotFoundException.class) - .hasMessage(MEMBER_NOT_FOUND.getMessage()); - } - - @Test - void 사용자의_티켓이_없으면_예외() { - // given - Long memberId = 1L; - Long memberTicketId = 1L; - - given(memberTicketRepository.findById(memberTicketId)) - .willReturn(Optional.empty()); - given(memberRepository.findById(memberId)) - .willReturn(Optional.of(new Member(memberId))); - - // when & then - assertThatThrownBy(() -> memberTicketService.findById(memberId, memberTicketId)) - .isInstanceOf(NotFoundException.class) - .hasMessage(MEMBER_TICKET_NOT_FOUND.getMessage()); - } - - @Test - void 사용자가_티켓의_주인이_아니면_예외() { - // given - Long memberId = 1L; - Member other = MemberFixture.member() - .id(2L) - .build(); - - MemberTicket otherMemberTicket = MemberTicketFixture.memberTicket() - .owner(other) - .build(); - - Long otherTicketId = otherMemberTicket.getId(); - - given(memberTicketRepository.findById(otherTicketId)) - .willReturn(Optional.of(otherMemberTicket)); - given(memberRepository.findById(memberId)) - .willReturn(Optional.of(new Member(memberId))); - - // when & then - assertThatThrownBy(() -> memberTicketService.findById(memberId, otherTicketId)) - .isInstanceOf(BadRequestException.class) - .hasMessage(NOT_MEMBER_TICKET_OWNER.getMessage()); - } - - @Test - void 성공() { - // given - Long memberId = 2L; - Member member = MemberFixture.member() - .id(memberId) - .build(); - Stage stage = StageFixture.stage() - .build(); - Long memberTicketId = 1L; - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .id(1L) - .owner(member) - .stage(stage) - .build(); - - given(memberTicketRepository.findById(memberTicketId)) - .willReturn(Optional.of(memberTicket)); - given(memberRepository.findById(memberId)) - .willReturn(Optional.of(new Member(memberId))); - - // when - MemberTicketResponse response = memberTicketService.findById(member.getId(), memberTicketId); - - // then - assertThat(response.id()).isEqualTo(memberTicketId); - } - } -} diff --git a/backend/src/test/java/com/festago/application/StageServiceTest.java b/backend/src/test/java/com/festago/application/StageServiceTest.java deleted file mode 100644 index a2261cd22..000000000 --- a/backend/src/test/java/com/festago/application/StageServiceTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.festago.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.anyLong; -import static org.mockito.BDDMockito.given; - -import com.festago.festival.domain.Festival; -import com.festago.festival.repository.FestivalRepository; -import com.festago.stage.application.StageService; -import com.festago.stage.domain.Stage; -import com.festago.stage.dto.StageCreateRequest; -import com.festago.stage.dto.StageResponse; -import com.festago.stage.repository.StageRepository; -import com.festago.support.FestivalFixture; -import java.time.LocalDateTime; -import java.util.Optional; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -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; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -@ExtendWith(MockitoExtension.class) -class StageServiceTest { - - @Mock - StageRepository stageRepository; - - @Mock - FestivalRepository festivalRepository; - - @InjectMocks - StageService stageService; - - @Test - void 무대_생성() { - // given - Festival festival = FestivalFixture.festival() - .build(); - StageCreateRequest request = new StageCreateRequest( - LocalDateTime.now(), - "애쉬,푸우,오리,글렌", - LocalDateTime.now().minusDays(1), - 1L - ); - given(festivalRepository.findById(anyLong())) - .willReturn(Optional.of(festival)); - given(stageRepository.save(any(Stage.class))) - .willAnswer(invocation -> invocation.getArgument(0)); - - // when - StageResponse response = stageService.create(request); - - // then - assertThat(response.startTime()).isEqualTo(request.startTime()); - } -} diff --git a/backend/src/test/java/com/festago/application/integration/ApplicationIntegrationTest.java b/backend/src/test/java/com/festago/application/integration/ApplicationIntegrationTest.java deleted file mode 100644 index e24b3510d..000000000 --- a/backend/src/test/java/com/festago/application/integration/ApplicationIntegrationTest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.festago.application.integration; - -import com.festago.support.DatabaseClearExtension; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; - -@SpringBootTest(webEnvironment = WebEnvironment.NONE) -@ExtendWith(DatabaseClearExtension.class) -public abstract class ApplicationIntegrationTest { - -} diff --git a/backend/src/test/java/com/festago/application/integration/FestivalServiceIntegrationTest.java b/backend/src/test/java/com/festago/application/integration/FestivalServiceIntegrationTest.java deleted file mode 100644 index d7d850f87..000000000 --- a/backend/src/test/java/com/festago/application/integration/FestivalServiceIntegrationTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.festago.application.integration; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.festago.festival.application.FestivalService; -import com.festago.festival.domain.Festival; -import com.festago.festival.dto.FestivalCreateRequest; -import com.festago.festival.dto.FestivalDetailResponse; -import com.festago.festival.dto.FestivalDetailStageResponse; -import com.festago.festival.dto.FestivalDetailTicketResponse; -import com.festago.festival.dto.FestivalResponse; -import com.festago.festival.repository.FestivalRepository; -import com.festago.school.domain.School; -import com.festago.school.repository.SchoolRepository; -import com.festago.stage.domain.Stage; -import com.festago.stage.repository.StageRepository; -import com.festago.support.FestivalFixture; -import com.festago.support.SchoolFixture; -import com.festago.support.StageFixture; -import com.festago.support.TicketFixture; -import com.festago.ticket.domain.Ticket; -import com.festago.ticket.domain.TicketType; -import com.festago.ticket.repository.TicketRepository; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import org.assertj.core.api.SoftAssertions; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class FestivalServiceIntegrationTest extends ApplicationIntegrationTest { - - @Autowired - FestivalService festivalService; - - @Autowired - FestivalRepository festivalRepository; - - @Autowired - StageRepository stageRepository; - - @Autowired - TicketRepository ticketRepository; - - @Autowired - SchoolRepository schoolRepository; - - - @Test - void 축제를_생성한다() { - // given - School school = schoolRepository.save(SchoolFixture.school().build()); - LocalDate today = LocalDate.now(); - FestivalCreateRequest request = new FestivalCreateRequest("테코 대학교 축제", today, today.plusDays(1), - "thumbnail.png", school.getId()); - - // when - FestivalResponse festivalResponse = festivalService.create(request); - - // then - assertThat(festivalResponse).isNotNull(); - } - - @Test - void 축제_상세_정보를_조회한다() { - // given - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); - Stage stage = stageRepository.save(StageFixture.stage().festival(festival).build()); - Ticket ticket1 = ticketRepository.save( - TicketFixture.ticket().stage(stage).ticketType(TicketType.VISITOR).build()); - LocalDateTime ticketOpenTime = stage.getTicketOpenTime(); - ticket1.addTicketEntryTime(ticketOpenTime.minusHours(1), LocalDateTime.now().minusMinutes(10), 100); - Ticket ticket2 = ticketRepository.save( - TicketFixture.ticket().stage(stage).ticketType(TicketType.STUDENT).build()); - ticket2.addTicketEntryTime(ticketOpenTime.minusHours(1), LocalDateTime.now().minusMinutes(10), 200); - - // when - FestivalDetailResponse response = festivalService.findDetail(festival.getId()); - - // then - SoftAssertions.assertSoftly(softly -> { - List stages = response.stages(); - softly.assertThat(response.id()).isEqualTo(festival.getId()); - softly.assertThat(stages.stream().map(FestivalDetailStageResponse::id).toList()) - .containsExactly(stage.getId()); - softly.assertThat(stages.get(0).tickets().stream().map(FestivalDetailTicketResponse::id).toList()) - .containsExactly(ticket1.getId(), ticket2.getId()); - }); - } -} diff --git a/backend/src/test/java/com/festago/application/integration/MemberTicketIntegrationTest.java b/backend/src/test/java/com/festago/application/integration/MemberTicketIntegrationTest.java deleted file mode 100644 index 275dc210b..000000000 --- a/backend/src/test/java/com/festago/application/integration/MemberTicketIntegrationTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.festago.application.integration; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.festago.festival.domain.Festival; -import com.festago.festival.repository.FestivalRepository; -import com.festago.member.domain.Member; -import com.festago.member.repository.MemberRepository; -import com.festago.school.domain.School; -import com.festago.school.repository.SchoolRepository; -import com.festago.stage.domain.Stage; -import com.festago.stage.repository.StageRepository; -import com.festago.support.FestivalFixture; -import com.festago.support.MemberFixture; -import com.festago.support.MemberTicketFixture; -import com.festago.support.SchoolFixture; -import com.festago.support.StageFixture; -import com.festago.ticket.repository.TicketRepository; -import com.festago.ticketing.application.MemberTicketService; -import com.festago.ticketing.dto.MemberTicketsResponse; -import com.festago.ticketing.repository.MemberTicketRepository; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.PageRequest; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class MemberTicketIntegrationTest extends ApplicationIntegrationTest { - - @Autowired - MemberTicketService memberTicketService; - - @Autowired - MemberRepository memberRepository; - - @Autowired - MemberTicketRepository memberTicketRepository; - - @Autowired - FestivalRepository festivalRepository; - - @Autowired - StageRepository stageRepository; - - @Autowired - TicketRepository ticketRepository; - - @Autowired - SchoolRepository schoolRepository; - - @Test - void 예매한_티켓_조회시_Pageable_적용() { - // given - School school = schoolRepository.save(SchoolFixture.school().build()); - Member member = memberRepository.save(MemberFixture.member().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); - Stage stage = stageRepository.save(StageFixture.stage().festival(festival).build()); - for (int i = 0; i < 20; i++) { - memberTicketRepository.save(MemberTicketFixture.memberTicket() - .stage(stage) - .owner(member) - .build() - ); - } - - // when - MemberTicketsResponse actual = memberTicketService.findAll(member.getId(), PageRequest.of(0, 10)); - - // then - assertThat(actual.memberTickets()).hasSize(10); - } -} diff --git a/backend/src/test/java/com/festago/application/integration/StageServiceIntegrationTest.java b/backend/src/test/java/com/festago/application/integration/StageServiceIntegrationTest.java deleted file mode 100644 index 31ecc5b58..000000000 --- a/backend/src/test/java/com/festago/application/integration/StageServiceIntegrationTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.festago.application.integration; - -import static com.festago.common.exception.ErrorCode.FESTIVAL_NOT_FOUND; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.festago.common.exception.NotFoundException; -import com.festago.festival.repository.FestivalRepository; -import com.festago.stage.application.StageService; -import com.festago.stage.dto.StageCreateRequest; -import java.time.LocalDateTime; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class StageServiceIntegrationTest extends ApplicationIntegrationTest { - - @Autowired - StageService stageService; - - @Autowired - FestivalRepository festivalRepository; - - @Test - void 축제가_없으면_예외() { - // given - String startTime = "2023-07-27T18:00:00"; - String lineUp = "글렌, 애쉬, 오리, 푸우"; - String ticketOpenTime = "2023-07-26T18:00:00"; - long invalidFestivalId = 1L; - - StageCreateRequest request = new StageCreateRequest(LocalDateTime.parse(startTime), lineUp, - LocalDateTime.parse(ticketOpenTime), - invalidFestivalId); - - // when && then - assertThatThrownBy(() -> stageService.create(request)) - .isInstanceOf(NotFoundException.class) - .hasMessage(FESTIVAL_NOT_FOUND.getMessage()); - } -} diff --git a/backend/src/test/java/com/festago/artist/application/ArtistCommandServiceTest.java b/backend/src/test/java/com/festago/artist/application/ArtistCommandServiceTest.java new file mode 100644 index 000000000..40423d4dc --- /dev/null +++ b/backend/src/test/java/com/festago/artist/application/ArtistCommandServiceTest.java @@ -0,0 +1,98 @@ +package com.festago.artist.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.mock; + +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.command.ArtistCreateCommand; +import com.festago.artist.dto.command.ArtistUpdateCommand; +import com.festago.artist.repository.ArtistRepository; +import com.festago.artist.repository.MemoryArtistRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.support.fixture.ArtistFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistCommandServiceTest { + + ArtistCommandService artistCommandService; + + ArtistRepository artistRepository; + + @BeforeEach + void setUp() { + artistRepository = new MemoryArtistRepository(); + artistCommandService = new ArtistCommandService(artistRepository, mock()); + } + + @Test + void 아티스트를_저장한다() { + // given + var command = ArtistCreateCommand.builder() + .name("윤서연") + .profileImageUrl("https://image.com/image.png") + .backgroundImageUrl("https://image.com/image.png") + .build(); + + // when + Long artistId = artistCommandService.save(command); + + // then + assertThat(artistRepository.findById(artistId)).isPresent(); + } + + @Test + void 중복된_이름의_아티스트가_저장되면_예외가_발생한다() { + // given + artistRepository.save(ArtistFixture.builder().name("윤서연").build()); + ArtistCreateCommand command = new ArtistCreateCommand("윤서연", "https://image.com/image.png", + "https://image.com/image.png"); + + // when & then + assertThatThrownBy(() -> artistCommandService.save(command)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DUPLICATE_ARTIST_NAME.getMessage()); + } + + @Test + void 아티스트_정보를_변경한다() { + // given + Long artistId = artistRepository.save(ArtistFixture.builder().name("고윤하").build()).getId(); + var command = ArtistUpdateCommand.builder() + .name("윤하") + .profileImageUrl("https://image.com/image2.png") + .backgroundImageUrl("https://image.com/image2.png") + .build(); + + // when + artistCommandService.update(command, artistId); + + // then + Artist actual = artistRepository.getOrThrow(artistId); + + assertSoftly(softly -> { + softly.assertThat(actual.getName()).isEqualTo(command.name()); + softly.assertThat(actual.getProfileImage()).isEqualTo(command.profileImageUrl()); + softly.assertThat(actual.getBackgroundImageUrl()).isEqualTo(command.backgroundImageUrl()); + }); + } + + @Test + void 아티스트를_삭제한다() { + // given + Long artistId = artistRepository.save(ArtistFixture.builder().name("고윤하").build()).getId(); + + // when + artistCommandService.delete(artistId); + + // then + assertThat(artistRepository.findById(artistId)).isEmpty(); + } +} diff --git a/backend/src/test/java/com/festago/artist/application/ArtistTotalSearchV1ServiceTest.java b/backend/src/test/java/com/festago/artist/application/ArtistTotalSearchV1ServiceTest.java new file mode 100644 index 000000000..fc99354b6 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/application/ArtistTotalSearchV1ServiceTest.java @@ -0,0 +1,86 @@ +package com.festago.artist.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.festago.artist.dto.ArtistSearchStageCountV1Response; +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.dto.ArtistTotalSearchV1Response; +import java.time.Clock; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class ArtistTotalSearchV1ServiceTest { + + @Mock + ArtistSearchV1QueryService artistSearchV1QueryService; + + @Mock + ArtistSearchStageCountV1QueryService artistSearchStageCountV1QueryService; + + @Spy + Clock clock = Clock.systemDefaultZone(); + + @InjectMocks + ArtistTotalSearchV1Service artistTotalSearchV1Service; + + @Test + void 아티스트_정보와_공연_일정을_종합하여_반환한다() { + List artists = List.of( + new ArtistSearchV1Response(1L, "아이브", "www.IVE-image.png"), + new ArtistSearchV1Response(2L, "아이유", "www.IU-image.png"), + new ArtistSearchV1Response(3L, "(여자)아이들", "www.IDLE-image.png")); + given(artistSearchV1QueryService.findAllByKeyword("아이")) + .willReturn(artists); + + LocalDate today = LocalDate.now(); + Map artistToStageSchedule = Map.of( + 1L, new ArtistSearchStageCountV1Response(1, 0), + 2L, new ArtistSearchStageCountV1Response(0, 0), + 3L, new ArtistSearchStageCountV1Response(0, 2)); + given(artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + List.of(1L, 2L, 3L), today.atStartOfDay())) + .willReturn(artistToStageSchedule); + + // when + List actual = artistTotalSearchV1Service.findAllByKeyword("아이"); + + // then + var expected = List.of( + new ArtistTotalSearchV1Response(1L, "아이브", "www.IVE-image.png", 1, 0), + new ArtistTotalSearchV1Response(2L, "아이유", "www.IU-image.png", 0, 0), + new ArtistTotalSearchV1Response(3L, "(여자)아이들", "www.IDLE-image.png", 0, 2) + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + void 검색_결과가_해당하는_아티스트가_없으면_빈리스트를_반환한다() { + // given + LocalDate today = LocalDate.now(); + given(artistSearchV1QueryService.findAllByKeyword("없어")) + .willReturn(Collections.emptyList()); + given(artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + Collections.emptyList(), today.atStartOfDay())) + .willReturn(Collections.emptyMap()); + + // when + List actual = artistTotalSearchV1Service.findAllByKeyword("없어"); + + // then + assertThat(actual).isEmpty(); + } +} diff --git a/backend/src/test/java/com/festago/artist/application/integration/ArtistDetailV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/artist/application/integration/ArtistDetailV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..faffc8613 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/application/integration/ArtistDetailV1QueryServiceIntegrationTest.java @@ -0,0 +1,280 @@ +package com.festago.artist.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; + +import com.festago.artist.application.ArtistDetailV1QueryService; +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistFestivalV1Response; +import com.festago.artist.dto.ArtistMediaV1Response; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.repository.SocialMediaRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.SocialMediaFixture; +import com.festago.support.fixture.StageArtistFixture; +import com.festago.support.fixture.StageFixture; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistDetailV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + Clock clock; + + @Autowired + ArtistDetailV1QueryService artistDetailV1QueryService; + + @Autowired + ArtistRepository artistRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + StageArtistRepository stageArtistRepository; + + @Autowired + SocialMediaRepository socialMediaRepository; + + @Nested + class 아티스트_상세_정보_조회 { + + @Test + void 조회할_수_있다() { + // given + Long 아티스트_식별자 = createArtist("pooh").getId(); + makeSocialMedia(아티스트_식별자, OwnerType.ARTIST, SocialMediaType.INSTAGRAM); + makeSocialMedia(아티스트_식별자, OwnerType.ARTIST, SocialMediaType.YOUTUBE); + + // when + var actual = artistDetailV1QueryService.findArtistDetail(아티스트_식별자); + + // then + assertSoftly(softly -> { + softly.assertThat(actual.id()).isEqualTo(아티스트_식별자); + softly.assertThat(actual.socialMedias()).hasSize(2); + }); + } + + @Test + void 소셜_미디어가_없어도_조회할_수_있다() { + // given + Long 아티스트_식별자 = createArtist("pooh").getId(); + + // when + var actual = artistDetailV1QueryService.findArtistDetail(아티스트_식별자); + + // then + assertSoftly(softly -> { + softly.assertThat(actual.id()).isEqualTo(아티스트_식별자); + softly.assertThat(actual.socialMedias()).isEmpty(); + }); + } + + @Test + void 소셜_미디어의_주인_아이디가_같더라도_주인의_타입에_따라_구분하여_조회한다() { + // given + Long 아티스트_식별자 = createArtist("pooh").getId(); + makeSocialMedia(아티스트_식별자, OwnerType.ARTIST, SocialMediaType.INSTAGRAM); + + // when + makeSocialMedia(아티스트_식별자, OwnerType.SCHOOL, SocialMediaType.YOUTUBE); + var actual = artistDetailV1QueryService.findArtistDetail(아티스트_식별자); + + // then + assertThat(actual.socialMedias()) + .map(ArtistMediaV1Response::type) + .containsExactly(SocialMediaType.INSTAGRAM); + } + + @Test + void 존재하지_않는_아티스트를_검색하면_에외() { + // given & when & then + assertThatThrownBy(() -> artistDetailV1QueryService.findArtistDetail(4885L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.ARTIST_NOT_FOUND.getMessage()); + } + + Long makeSocialMedia(Long ownerId, OwnerType ownerType, SocialMediaType socialMediaType) { + var socialMedia = SocialMediaFixture.builder() + .ownerId(ownerId) + .ownerType(ownerType) + .mediaType(socialMediaType) + .build(); + return socialMediaRepository.save(socialMedia).getId(); + } + } + + /** + * 현재 시간은 6월 15일 18시 0분이다.

각 축제는 다음과 같이 진행 된다.
6월 14일~14일 서울대학교 축제
6월 15일~15일 부산대학교 축제
6월 + * 16일~16일 대구대학교 축제

서울대학교 축제는 종료된 상태이다.
부산대학교 축제는 진행 중 상태이다.
대구대학교 축제는 진행 예정 상태이다.

+ * 아티스트A는 위 세 축제의 공연에 참여한 상태이다. + */ + @Nested + class 아티스트가_참여한_축제_목록_조회 { + + LocalDateTime now = LocalDateTime.parse("2077-06-15T18:00:00"); + LocalDate _6월_14일 = LocalDate.parse("2077-06-14"); + LocalDate _6월_15일 = LocalDate.parse("2077-06-15"); + LocalDate _6월_16일 = LocalDate.parse("2077-06-16"); + + Artist 아티스트A; + + Festival 서울대학교_축제; + Festival 부산대학교_축제; + Festival 대구대학교_축제; + + @BeforeEach + void setUp() { + School 서울대학교 = createSchool("서울대학교", "seoul.ac.kr", SchoolRegion.서울); + School 부산대학교 = createSchool("부산대학교", "busan.ac.kr", SchoolRegion.부산); + School 대구대학교 = createSchool("대구대학교", "daegu.ac.kr", SchoolRegion.대구); + + 서울대학교_축제 = createFestival("서울대학교 축제", _6월_14일, _6월_14일, 서울대학교); + 부산대학교_축제 = createFestival("부산대학교 축제", _6월_15일, _6월_15일, 부산대학교); + 대구대학교_축제 = createFestival("대구대학교 축제", _6월_16일, _6월_16일, 대구대학교); + + 아티스트A = createArtist("아티스트A"); + + createStage(서울대학교_축제, _6월_14일.atTime(18, 0), 아티스트A); + createStage(부산대학교_축제, _6월_15일.atTime(18, 0), 아티스트A); + createStage(대구대학교_축제, _6월_16일.atTime(18, 0), 아티스트A); + + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + } + + private Festival createFestival(String festivalName, LocalDate startDate, LocalDate endDate, School school) { + return festivalRepository.save(FestivalFixture.builder() + .name(festivalName) + .startDate(startDate) + .endDate(endDate) + .school(school) + .build() + ); + } + + private void createStage(Festival festival, LocalDateTime startTime, Artist... artists) { + Stage stage = stageRepository.save(StageFixture.builder() + .festival(festival) + .startTime(startTime) + .build() + ); + for (Artist artist : artists) { + stageArtistRepository.save(StageArtistFixture.builder(stage.getId(), artist.getId()).build()); + } + } + + @Test + void 진행중인_축제_조회가_가능하다() { + // given & when + var actual = artistDetailV1QueryService.findArtistFestivals( + 아티스트A.getId(), + null, + null, + false, + PageRequest.ofSize(10) + ); + + // then + assertThat(actual.getContent()) + .map(ArtistFestivalV1Response::id) + .containsExactly(부산대학교_축제.getId(), 대구대학교_축제.getId()); + } + + @Test + void 종료된_축제_조회가_가능하다() { + // given & when + var actual = artistDetailV1QueryService.findArtistFestivals( + 아티스트A.getId(), + null, + null, + true, + PageRequest.ofSize(10) + ); + + // then + assertThat(actual.getContent()) + .map(ArtistFestivalV1Response::id) + .containsExactly(서울대학교_축제.getId()); + } + + @Test + void 커서_기반_페이징이_가능하다() { + // given + var firstResponse = artistDetailV1QueryService.findArtistFestivals( + 아티스트A.getId(), + null, + null, + false, + PageRequest.ofSize(1) + ); + + var firstFestivalResponse = firstResponse.getContent().get(0); + + // when + var secondResponse = artistDetailV1QueryService.findArtistFestivals( + 아티스트A.getId(), + firstFestivalResponse.id(), + firstFestivalResponse.startDate(), + false, + PageRequest.ofSize(1) + ); + + // then + assertThat(secondResponse.getContent()) + .map(ArtistFestivalV1Response::id) + .containsExactly(대구대학교_축제.getId()); + } + } + + private School createSchool(String schoolName, String domain, SchoolRegion region) { + return schoolRepository.save(SchoolFixture.builder() + .name(schoolName) + .domain(domain) + .region(region) + .build() + ); + } + + private Artist createArtist(String artistName) { + return artistRepository.save(ArtistFixture.builder() + .name(artistName) + .build() + ); + } +} diff --git a/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchStageCountV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchStageCountV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..d85494b84 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchStageCountV1QueryServiceIntegrationTest.java @@ -0,0 +1,155 @@ +package com.festago.artist.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.application.ArtistSearchStageCountV1QueryService; +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistSearchStageCountV1Response; +import com.festago.artist.repository.ArtistRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageArtistFixture; +import com.festago.support.fixture.StageFixture; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistSearchStageCountV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + ArtistSearchStageCountV1QueryService artistSearchStageCountV1QueryService; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + ArtistRepository artistRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + StageArtistRepository stageArtistRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Nested + class 검색 { + + Artist 아이유; + Artist 아이브; + Artist 아이들; + Stage _6월_15일_공연; + Stage _6월_16일_공연; + Stage _6월_17일_공연; + LocalDate _6월_15일 = LocalDate.parse("2077-06-15"); + LocalDate _6월_16일 = LocalDate.parse("2077-06-16"); + LocalDate _6월_17일 = LocalDate.parse("2077-06-17"); + + @BeforeEach + void setUp() { + 아이유 = artistRepository.save(ArtistFixture.builder().name("아이유").build()); + 아이브 = artistRepository.save(ArtistFixture.builder().name("아이브").build()); + 아이들 = artistRepository.save(ArtistFixture.builder().name("아이들").build()); + var school = schoolRepository.save( + SchoolFixture.builder() + .domain("knu.ac.kr") + .name("경북대") + .region(SchoolRegion.대구) + .build() + ); + var festival = festivalRepository.save( + FestivalFixture.builder() + .name("축제") + .startDate(_6월_15일) + .endDate(_6월_17일) + .school(school) + .build() + ); + + _6월_15일_공연 = stageRepository.save(StageFixture.builder() + .startTime(_6월_15일.atStartOfDay()) + .festival(festival) + .build() + ); + _6월_16일_공연 = stageRepository.save(StageFixture.builder() + .startTime(_6월_16일.atStartOfDay()) + .festival(festival) + .build() + ); + _6월_17일_공연 = stageRepository.save(StageFixture.builder() + .startTime(_6월_17일.atStartOfDay()) + .festival(festival) + .build() + ); + } + + @Test + void 아티스트의_당일_및_예정_공연_갯수를_조회한다() { + // given + LocalDateTime today = _6월_16일.atStartOfDay(); + + saveStageArtist(아이유, _6월_16일_공연); + var 아이유_공연_갯수 = new ArtistSearchStageCountV1Response(1, 0); + + saveStageArtist(아이브, _6월_16일_공연); + saveStageArtist(아이브, _6월_17일_공연); + var 아이브_공연_갯수 = new ArtistSearchStageCountV1Response(1, 1); + + saveStageArtist(아이들, _6월_15일_공연); + saveStageArtist(아이들, _6월_17일_공연); + var 아이들_공연_갯수 = new ArtistSearchStageCountV1Response(0, 1); + + // when + List artistIds = List.of(아이브.getId(), 아이유.getId(), 아이들.getId()); + var actual = artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + artistIds, today + ); + + assertSoftly(softly -> { + softly.assertThat(actual).containsEntry(아이유.getId(), 아이유_공연_갯수); + softly.assertThat(actual).containsEntry(아이브.getId(), 아이브_공연_갯수); + softly.assertThat(actual).containsEntry(아이들.getId(), 아이들_공연_갯수); + }); + } + + @Test + void 아티스트가_오늘_이후_공연이_없으면_0개() { + LocalDateTime today = _6월_16일.atStartOfDay(); + + saveStageArtist(아이브, _6월_15일_공연); + var 아이브_공연_갯수 = new ArtistSearchStageCountV1Response(0, 0); + + // when + List artistIds = List.of(아이브.getId()); + var actual = artistSearchStageCountV1QueryService.findArtistsStageCountAfterDateTime( + artistIds, today + ); + + // then + assertThat(actual).containsEntry(아이브.getId(), 아이브_공연_갯수); + } + } + + private void saveStageArtist(Artist artist, Stage stage) { + stageArtistRepository.save(StageArtistFixture.builder(stage.getId(), artist.getId()).build()); + } +} diff --git a/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..b1836c3e8 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/application/integration/ArtistSearchV1QueryServiceIntegrationTest.java @@ -0,0 +1,102 @@ +package com.festago.artist.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.application.ArtistSearchV1QueryService; +import com.festago.artist.domain.Artist; +import com.festago.artist.dto.ArtistSearchV1Response; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.ArtistFixture; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistSearchV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + ArtistSearchV1QueryService artistSearchV1QueryService; + + @Autowired + ArtistRepository artistRepository; + + @Test + void 검색어가_한글자면_동등검색을_한다() { + // given + artistRepository.save(ArtistFixture.builder().name("난못").build()); + artistRepository.save(ArtistFixture.builder().name("못난").build()); + artistRepository.save(ArtistFixture.builder().name("못").build()); + + // when + List actual = artistSearchV1QueryService.findAllByKeyword("못"); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(1); + softly.assertThat(actual.get(0).name()).isEqualTo("못"); + }); + } + + @Test + void 검색어가_두글자_이상이면_like검색을_한다() { + // given + artistRepository.save(ArtistFixture.builder().name("에이핑크").build()); + artistRepository.save(ArtistFixture.builder().name("블랙핑크").build()); + artistRepository.save(ArtistFixture.builder().name("핑크").build()); + artistRepository.save(ArtistFixture.builder().name("핑크 플로이드").build()); + artistRepository.save(ArtistFixture.builder().name("핑").build()); + artistRepository.save(ArtistFixture.builder().name("크").build()); + + // when + List actual = artistSearchV1QueryService.findAllByKeyword("핑크"); + + // then + assertThat(actual).hasSize(4); + } + + @Test + void 아티스트명은_영어_한국어_순으로_오름차순_정렬된다() { + // given + Artist 가_아티스트 = artistRepository.save(ArtistFixture.builder().name("가_아티스트").build()); + Artist A_아티스트 = artistRepository.save(ArtistFixture.builder().name("A_아티스트").build()); + Artist 나_아티스트 = artistRepository.save(ArtistFixture.builder().name("나_아티스트").build()); + Artist C_아티스트 = artistRepository.save(ArtistFixture.builder().name("C_아티스트").build()); + + // when + List actual = artistSearchV1QueryService.findAllByKeyword("아티스트"); + + // then + List result = actual.stream() + .map(ArtistSearchV1Response::id) + .toList(); + assertThat(result).isEqualTo(List.of(A_아티스트.getId(), C_아티스트.getId(), 가_아티스트.getId(), 나_아티스트.getId())); + } + + @Test + void 검색결과가_10개_이상이면_예외() { + // given + for (int i = 0; i < 10; i++) { + artistRepository.save(ArtistFixture.builder().name("핑크").build()); + } + + // when && then + assertThatThrownBy(() -> artistSearchV1QueryService.findAllByKeyword("핑크")) + .isInstanceOf(BadRequestException.class); + } + + @Test + void 검색_결과가_없다면_빈리스트_반환() { + // when + List actual = artistSearchV1QueryService.findAllByKeyword("없음"); + + // then + assertThat(actual).isEmpty(); + } +} diff --git a/backend/src/test/java/com/festago/artist/infrastructure/DelimiterArtistsSerializer.java b/backend/src/test/java/com/festago/artist/infrastructure/DelimiterArtistsSerializer.java new file mode 100644 index 000000000..92251d9f4 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/infrastructure/DelimiterArtistsSerializer.java @@ -0,0 +1,23 @@ +package com.festago.artist.infrastructure; + +import static java.util.stream.Collectors.joining; + +import com.festago.artist.domain.Artist; +import com.festago.artist.domain.ArtistsSerializer; +import java.util.List; + +public class DelimiterArtistsSerializer implements ArtistsSerializer { + + private final String delimiter; + + public DelimiterArtistsSerializer(String delimiter) { + this.delimiter = delimiter; + } + + @Override + public String serialize(List artists) { + return artists.stream() + .map(Artist::getName) + .collect(joining(delimiter)); + } +} diff --git a/backend/src/test/java/com/festago/artist/presentation/v1/ArtistSearchV1ControllerTest.java b/backend/src/test/java/com/festago/artist/presentation/v1/ArtistSearchV1ControllerTest.java new file mode 100644 index 000000000..fd5087132 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/presentation/v1/ArtistSearchV1ControllerTest.java @@ -0,0 +1,80 @@ +package com.festago.artist.presentation.v1; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.artist.application.ArtistTotalSearchV1Service; +import com.festago.artist.dto.ArtistTotalSearchV1Response; +import com.festago.support.CustomWebMvcTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistSearchV1ControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ArtistTotalSearchV1Service artistTotalSearchV1Service; + + @Autowired + ObjectMapper objectMapper; + + @Nested + class 아티스트_검색_조회 { + + final String uri = "/api/v1/search/artists"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var expected = List.of( + new ArtistTotalSearchV1Response(1L, "블랙핑크", "www.profileImage.png", 1, 1), + new ArtistTotalSearchV1Response(2L, "에이핑크", "www.profileImage.png", 0, 0) + ); + + given(artistTotalSearchV1Service.findAllByKeyword("핑크")) + .willReturn(expected); + + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .param("keyword", "핑크")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(expected))); + } + + @ParameterizedTest + @NullAndEmptySource + void 키워드가_빈값이거나_null이면_400을_반환한다(String keyword) throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .param("keyword", keyword)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/artist/presentation/v1/ArtistV1ControllerTest.java b/backend/src/test/java/com/festago/artist/presentation/v1/ArtistV1ControllerTest.java new file mode 100644 index 000000000..c37652e2c --- /dev/null +++ b/backend/src/test/java/com/festago/artist/presentation/v1/ArtistV1ControllerTest.java @@ -0,0 +1,120 @@ +package com.festago.artist.presentation.v1; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.artist.application.ArtistDetailV1QueryService; +import com.festago.artist.dto.ArtistDetailV1Response; +import com.festago.artist.dto.ArtistFestivalV1Response; +import com.festago.artist.dto.ArtistMediaV1Response; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.support.CustomWebMvcTest; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistV1ControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ArtistDetailV1QueryService artistDetailV1QueryService; + + @Autowired + ObjectMapper objectMapper; + + @Nested + class 아티스트_상세_조회 { + + final String uri = "/api/v1/artists/{artistId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var expected = new ArtistDetailV1Response( + 1L, "경북대학교", + "https://image.com/logo.png", + "https://image.com/backgroundLogo.png", + List.of( + new ArtistMediaV1Response(SocialMediaType.YOUTUBE, "유튜브", + "https://image.com/youtube.png", "www.knu-youtube.com"), + new ArtistMediaV1Response(SocialMediaType.INSTAGRAM, "인스타그램", + "https://image.com/youtube.png", "www.knu-instagram.com") + ) + ); + given(artistDetailV1QueryService.findArtistDetail(expected.id())) + .willReturn(expected); + + // when & then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + } + } + + + @Nested + class 아티스트별_축제_조회 { + + final String uri = "/api/v1/artists/{artistId}/festivals"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var today = LocalDate.now(); + var content = List.of(new ArtistFestivalV1Response( + 1L, "경북대학교", today, today.plusDays(1), "www.image.com/image.png", + "아티스트" + )); + var slice = new SliceImpl<>(content, Pageable.ofSize(10), true); + given(artistDetailV1QueryService.findArtistFestivals(1L, null, null, false, Pageable.ofSize(10))) + .willReturn(slice); + + // when & then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void 요청시_페이지가_20을_넘어가면_예외() throws Exception { + // given + int maxPageSize = 20; + + // when && then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON) + .param("size", String.valueOf(maxPageSize + 1))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/artist/repository/MemoryArtistRepository.java b/backend/src/test/java/com/festago/artist/repository/MemoryArtistRepository.java new file mode 100644 index 000000000..3e12fb6c5 --- /dev/null +++ b/backend/src/test/java/com/festago/artist/repository/MemoryArtistRepository.java @@ -0,0 +1,30 @@ +package com.festago.artist.repository; + +import com.festago.artist.domain.Artist; +import com.festago.support.AbstractMemoryRepository; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +public class MemoryArtistRepository extends AbstractMemoryRepository implements ArtistRepository { + + @Override + public long countByIdIn(List artistIds) { + return memory.values().stream() + .filter(artist -> artistIds.contains(artist.getId())) + .count(); + } + + @Override + public List findByIdIn(Collection artistIds) { + return memory.values().stream() + .filter(artist -> artistIds.contains(artist.getId())) + .toList(); + } + + @Override + public boolean existsByName(String name) { + return memory.values().stream() + .anyMatch(it -> Objects.equals(it.getName(), name)); + } +} diff --git a/backend/src/test/java/com/festago/auth/application/AdminAuthServiceTest.java b/backend/src/test/java/com/festago/auth/application/AdminAuthServiceTest.java deleted file mode 100644 index 94e621354..000000000 --- a/backend/src/test/java/com/festago/auth/application/AdminAuthServiceTest.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.festago.auth.application; - -import static com.festago.common.exception.ErrorCode.DUPLICATE_ACCOUNT_USERNAME; -import static com.festago.common.exception.ErrorCode.INCORRECT_PASSWORD_OR_ACCOUNT; -import static com.festago.common.exception.ErrorCode.NOT_ENOUGH_PERMISSION; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.anyLong; -import static org.mockito.BDDMockito.anyString; -import static org.mockito.BDDMockito.given; - -import com.festago.admin.domain.Admin; -import com.festago.admin.repository.AdminRepository; -import com.festago.auth.dto.AdminLoginRequest; -import com.festago.auth.dto.AdminSignupRequest; -import com.festago.auth.dto.AdminSignupResponse; -import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.ForbiddenException; -import com.festago.common.exception.UnauthorizedException; -import java.util.Optional; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Nested; -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; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -@ExtendWith(MockitoExtension.class) -class AdminAuthServiceTest { - - @Mock - AuthProvider authProvider; - - @Mock - AdminRepository adminRepository; - - @InjectMocks - AdminAuthService adminAuthService; - - @Nested - class 로그인 { - - @Test - void 계정이_없으면_예외() { - // given - AdminLoginRequest request = new AdminLoginRequest("admin", "admin"); - given(adminRepository.findByUsername(anyString())) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> adminAuthService.login(request)) - .isInstanceOf(UnauthorizedException.class) - .hasMessage(INCORRECT_PASSWORD_OR_ACCOUNT.getMessage()); - } - - @Test - void 비밀번호가_틀리면_예외() { - // given - Admin admin = new Admin(1L, "admin", "admin"); - AdminLoginRequest request = new AdminLoginRequest("admin", "password"); - given(adminRepository.findByUsername(anyString())) - .willReturn(Optional.of(admin)); - - // when & then - assertThatThrownBy(() -> adminAuthService.login(request)) - .isInstanceOf(UnauthorizedException.class) - .hasMessage(INCORRECT_PASSWORD_OR_ACCOUNT.getMessage()); - } - - @Test - void 성공() { - // given - Admin admin = new Admin(1L, "admin", "admin"); - AdminLoginRequest request = new AdminLoginRequest("admin", "admin"); - given(adminRepository.findByUsername(anyString())) - .willReturn(Optional.of(admin)); - given(authProvider.provide(any())) - .willReturn("token"); - - // when - String token = adminAuthService.login(request); - - // then - assertThat(token).isEqualTo("token"); - } - } - - @Nested - class 가입 { - - @Test - void 닉네임이_중복이면_예외() { - // given - AdminSignupRequest request = new AdminSignupRequest("admin", "admin"); - given(adminRepository.existsByUsername(anyString())) - .willReturn(true); - given(adminRepository.findById(anyLong())) - .willReturn(Optional.of(new Admin("admin", "admin"))); - - // when & then - assertThatThrownBy(() -> adminAuthService.signup(1L, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage(DUPLICATE_ACCOUNT_USERNAME.getMessage()); - } - - @Test - void Root_어드민이_아니면_예외() { - // given - AdminSignupRequest request = new AdminSignupRequest("newAdmin", "newAdmin"); - given(adminRepository.findById(anyLong())) - .willReturn(Optional.of(new Admin("mewAdmin", "newAdmin"))); - - // when & then - assertThatThrownBy(() -> adminAuthService.signup(1L, request)) - .isInstanceOf(ForbiddenException.class) - .hasMessage(NOT_ENOUGH_PERMISSION.getMessage()); - } - - @Test - void 성공() { - // given - AdminSignupRequest request = new AdminSignupRequest("newAdmin", "newAdmin"); - given(adminRepository.save(any(Admin.class))) - .willReturn(new Admin(1L, "newAdmin", "newAdmin")); - given(adminRepository.findById(anyLong())) - .willReturn(Optional.of(new Admin(1L, "admin", "admin"))); - - // when - AdminSignupResponse response = adminAuthService.signup(1L, request); - - // then - assertThat(response.username()).isEqualTo("newAdmin"); - } - } -} diff --git a/backend/src/test/java/com/festago/auth/application/AuthFacadeServiceTest.java b/backend/src/test/java/com/festago/auth/application/AuthFacadeServiceTest.java deleted file mode 100644 index f2f51ef1a..000000000 --- a/backend/src/test/java/com/festago/auth/application/AuthFacadeServiceTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.festago.auth.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.mock; - -import com.festago.auth.domain.AuthPayload; -import com.festago.auth.domain.SocialType; -import com.festago.auth.domain.UserInfo; -import com.festago.auth.dto.LoginMemberDto; -import com.festago.auth.dto.LoginResponse; -import com.festago.auth.infrastructure.FestagoOAuth2Client; -import com.festago.member.domain.Member; -import com.festago.support.MemberFixture; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -@ExtendWith(MockitoExtension.class) -class AuthFacadeServiceTest { - - AuthFacadeService authFacadeService; - - AuthService authService; - - AuthProvider authProvider; - - @BeforeEach - void setUp() { - authService = mock(AuthService.class); - OAuth2Clients oAuth2Clients = OAuth2Clients.builder() - .add(new FestagoOAuth2Client()) - .build(); - authProvider = mock(AuthProvider.class); - - authFacadeService = new AuthFacadeService(authService, oAuth2Clients, authProvider); - } - - @Test - void 로그인() { - Member member = MemberFixture.member() - .id(1L) - .build(); - - given(authProvider.provide(any(AuthPayload.class))) - .willReturn("Bearer token"); - - given(authService.login(any(UserInfo.class))) - .willReturn(new LoginMemberDto(false, member.getId(), member.getNickname())); - - // when - LoginResponse response = authFacadeService.login(SocialType.FESTAGO, "1"); - - // then - assertThat(response) - .isNotNull(); - } -} diff --git a/backend/src/test/java/com/festago/auth/application/AuthServiceTest.java b/backend/src/test/java/com/festago/auth/application/AuthServiceTest.java deleted file mode 100644 index 36893f550..000000000 --- a/backend/src/test/java/com/festago/auth/application/AuthServiceTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.festago.auth.application; - -import static com.festago.common.exception.ErrorCode.MEMBER_NOT_FOUND; -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.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; - -import com.festago.auth.domain.SocialType; -import com.festago.auth.domain.UserInfo; -import com.festago.auth.dto.LoginMemberDto; -import com.festago.common.exception.NotFoundException; -import com.festago.member.domain.Member; -import com.festago.member.repository.MemberRepository; -import com.festago.support.MemberFixture; -import java.util.Optional; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Nested; -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; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -@ExtendWith(MockitoExtension.class) -class AuthServiceTest { - - @Mock - MemberRepository memberRepository; - - @InjectMocks - AuthService authService; - - @Nested - class 로그인 { - - @Test - void 신규_회원으로_로그인() { - // given - Member member = MemberFixture.member() - .id(1L) - .build(); - given(memberRepository.findBySocialIdAndSocialType(anyString(), any(SocialType.class))) - .willReturn(Optional.empty()); - given(memberRepository.save(any(Member.class))) - .willReturn(member); - UserInfo userInfo = new UserInfo(member.getSocialId(), member.getSocialType(), member.getNickname(), - member.getProfileImage()); - - // when - LoginMemberDto response = authService.login(userInfo); - - // then - assertThat(response.isNew()) - .isTrue(); - } - - @Test - void 기존_회원으로_로그인() { - // given - Member member = MemberFixture.member() - .id(1L) - .build(); - given(memberRepository.findBySocialIdAndSocialType(anyString(), any(SocialType.class))) - .willReturn(Optional.of(member)); - UserInfo userInfo = new UserInfo(member.getSocialId(), member.getSocialType(), member.getNickname(), - member.getProfileImage()); - - // when - LoginMemberDto response = authService.login(userInfo); - - // then - assertThat(response.isNew()) - .isFalse(); - } - } - - @Nested - class 회원탈퇴 { - - @Test - void 멤버가_없으면_예외() { - // given - Long memberId = 1L; - given(memberRepository.findById(memberId)) - .willReturn(Optional.empty()); - - // then - assertThatThrownBy(() -> authService.deleteMember(memberId)) - .isInstanceOf(NotFoundException.class) - .hasMessage(MEMBER_NOT_FOUND.getMessage()); - } - - @Test - void 성공() { - // given - Long memberId = 1L; - Member member = MemberFixture.member().id(memberId).build(); - given(memberRepository.findById(memberId)) - .willReturn(Optional.of(member)); - - // when & then - assertThatNoException() - .isThrownBy(() -> authService.deleteMember(memberId)); - } - } -} diff --git a/backend/src/test/java/com/festago/auth/application/command/AdminAuthCommandServiceTest.java b/backend/src/test/java/com/festago/auth/application/command/AdminAuthCommandServiceTest.java new file mode 100644 index 000000000..d4be91a28 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/application/command/AdminAuthCommandServiceTest.java @@ -0,0 +1,190 @@ +package com.festago.auth.application.command; + +import static com.festago.common.exception.ErrorCode.DUPLICATE_ACCOUNT_USERNAME; +import static com.festago.common.exception.ErrorCode.INCORRECT_PASSWORD_OR_ACCOUNT; +import static com.festago.common.exception.ErrorCode.NOT_ENOUGH_PERMISSION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import com.festago.admin.domain.Admin; +import com.festago.admin.repository.AdminRepository; +import com.festago.admin.repository.MemoryAdminRepository; +import com.festago.auth.dto.command.AdminLoginCommand; +import com.festago.auth.dto.command.AdminSignupCommand; +import com.festago.auth.dto.v1.TokenResponse; +import com.festago.auth.infrastructure.AdminAuthenticationTokenProvider; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.ForbiddenException; +import com.festago.common.exception.UnauthorizedException; +import com.festago.support.fixture.AdminFixture; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminAuthCommandServiceTest { + + AdminRepository adminRepository; + + AdminAuthenticationTokenProvider adminAuthenticationTokenProvider; + + AdminAuthCommandService adminAuthCommandService; + + @BeforeEach + void setUp() { + adminRepository = new MemoryAdminRepository(); + adminAuthenticationTokenProvider = mock(AdminAuthenticationTokenProvider.class); + adminAuthCommandService = new AdminAuthCommandService( + adminAuthenticationTokenProvider, + adminRepository, + PasswordEncoderFactories.createDelegatingPasswordEncoder() + ); + } + + @Nested + class 로그인 { + + @Test + void 계정이_없으면_예외() { + // given + var command = AdminLoginCommand.builder() + .username("admin") + .password("password") + .build(); + + // when & then + assertThatThrownBy(() -> adminAuthCommandService.login(command)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(INCORRECT_PASSWORD_OR_ACCOUNT.getMessage()); + } + + @Test + void 비밀번호가_틀리면_예외() { + // given + adminRepository.save(AdminFixture.builder() + .username("admin") + .password("{noop}password") + .build()); + var command = AdminLoginCommand.builder() + .username("admin") + .password("admin") + .build(); + + // when & then + assertThatThrownBy(() -> adminAuthCommandService.login(command)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(INCORRECT_PASSWORD_OR_ACCOUNT.getMessage()); + } + + @Test + void 성공() { + // given + adminRepository.save(AdminFixture.builder() + .username("admin") + .password("{noop}password") + .build()); + var command = AdminLoginCommand.builder() + .username("admin") + .password("password") + .build(); + given(adminAuthenticationTokenProvider.provide(any())) + .willReturn(new TokenResponse("token", LocalDateTime.now().plusWeeks(1))); + + // when + var result = adminAuthCommandService.login(command); + + // then + assertThat(result.accessToken()).isEqualTo("token"); + } + } + + @Nested + class 가입 { + + @Test + void 닉네임이_중복이면_예외() { + // given + Admin rootAdmin = adminRepository.save(Admin.createRootAdmin("{noop}password")); + var command = AdminSignupCommand.builder() + .username("admin") + .password("password") + .build(); + + // when & then + Long rootAdminId = rootAdmin.getId(); + assertThatThrownBy(() -> adminAuthCommandService.signup(rootAdminId, command)) + .isInstanceOf(BadRequestException.class) + .hasMessage(DUPLICATE_ACCOUNT_USERNAME.getMessage()); + } + + @Test + void Root_어드민이_아니면_예외() { + // given + Admin admin = adminRepository.save(AdminFixture.builder() + .username("glen") + .password("{noop}password") + .build()); + var command = AdminSignupCommand.builder() + .username("newAdmin") + .password("password") + .build(); + + // when & then + Long adminId = admin.getId(); + assertThatThrownBy(() -> adminAuthCommandService.signup(adminId, command)) + .isInstanceOf(ForbiddenException.class) + .hasMessage(NOT_ENOUGH_PERMISSION.getMessage()); + } + + @Test + void 성공() { + // given + Admin rootAdmin = adminRepository.save(Admin.createRootAdmin("{noop}password")); + var command = AdminSignupCommand.builder() + .username("newAdmin") + .password("password") + .build(); + + // when + adminAuthCommandService.signup(rootAdmin.getId(), command); + + // then + assertThat(adminRepository.existsByUsername(command.username())).isTrue(); + } + } + + @Nested + class 루트_어드민_초기화 { + + @Test + void 루트_어드민을_활성화하면_저장된다() { + // when + adminAuthCommandService.initializeRootAdmin("1234"); + + // then + Admin rootAdmin = Admin.createRootAdmin("1234"); + assertThat(adminRepository.existsByUsername(rootAdmin.getUsername())) + .isTrue(); + } + + @Test + void 루트_어드민이_존재하는데_초기화하면_예외() { + // given + adminAuthCommandService.initializeRootAdmin("1234"); + + // when & then + assertThatThrownBy(() -> adminAuthCommandService.initializeRootAdmin("1234")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DUPLICATE_ACCOUNT_USERNAME.getMessage()); + } + } +} diff --git a/backend/src/test/java/com/festago/auth/application/command/MemberAuthCommandServiceTest.java b/backend/src/test/java/com/festago/auth/application/command/MemberAuthCommandServiceTest.java new file mode 100644 index 000000000..8a6dd0c00 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/application/command/MemberAuthCommandServiceTest.java @@ -0,0 +1,196 @@ +package com.festago.auth.application.command; + +import static java.util.UUID.fromString; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.spy; + +import com.festago.auth.domain.RefreshToken; +import com.festago.auth.domain.SocialType; +import com.festago.auth.domain.UserInfo; +import com.festago.auth.domain.UserInfoMemberMapper; +import com.festago.auth.repository.MemoryRefreshTokenRepository; +import com.festago.auth.repository.RefreshTokenRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.exception.UnauthorizedException; +import com.festago.member.domain.DefaultNicknamePolicy; +import com.festago.member.domain.Member; +import com.festago.member.repository.MemberRepository; +import com.festago.member.repository.MemoryMemberRepository; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.RefreshTokenFixture; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberAuthCommandServiceTest { + + MemberAuthCommandService memberAuthCommandService; + + MemberRepository memberRepository; + + RefreshTokenRepository refreshTokenRepository; + + Clock clock; + + @BeforeEach + void setUp() { + clock = spy(Clock.systemDefaultZone()); + memberRepository = new MemoryMemberRepository(); + refreshTokenRepository = new MemoryRefreshTokenRepository(); + DefaultNicknamePolicy defaultNicknamePolicy = () -> "nickname"; + memberAuthCommandService = new MemberAuthCommandService( + memberRepository, + refreshTokenRepository, + mock(ApplicationEventPublisher.class), + new UserInfoMemberMapper(defaultNicknamePolicy), + clock + ); + } + + @Nested + class login { + + @Test + void 신규_회원으로_로그인하면_회원과_리프래쉬_토큰이_저장된다() { + // when + var actual = memberAuthCommandService.login(getUserInfo("1")); + + // then + assertThat(memberRepository.findById(actual.memberId())).isPresent(); + assertThat(refreshTokenRepository.findById(actual.refreshToken())).isPresent(); + } + + @Test + void 기존_회원으로_로그인해도_기존_리프레쉬_토큰이_삭제되지_않는다() { + // given + Member member = memberRepository.save(MemberFixture.builder().build()); + RefreshToken originToken = refreshTokenRepository.save( + RefreshTokenFixture.builder().memberId(member.getId()).build()); + + // when + var actual = memberAuthCommandService.login(getUserInfo(member.getSocialId())); + + // then + assertThat(refreshTokenRepository.findById(originToken.getId())).isPresent(); + assertThat(refreshTokenRepository.findById(actual.refreshToken())).isPresent(); + } + } + + @Nested + class logout { + + @Test + void 회원의_리프래쉬_토큰이_삭제된다() { + // given + Member member = memberRepository.save(MemberFixture.builder().build()); + RefreshToken originToken = refreshTokenRepository.save( + RefreshTokenFixture.builder().memberId(member.getId()).build()); + + // when + memberAuthCommandService.logout(member.getId(), originToken.getId()); + + // then + assertThat(refreshTokenRepository.findById(originToken.getId())).isEmpty(); + } + + @Test + void 다른_회원의_리프래쉬_토큰으로_로그아웃하면_해당_리프래쉬_토큰은_삭제되지_않는다() { + // given + Member 회원A = memberRepository.save(MemberFixture.builder().build()); + Member 회원B = memberRepository.save(MemberFixture.builder().build()); + RefreshToken 회원A_리프래쉬_토큰 = refreshTokenRepository.save( + RefreshTokenFixture.builder().memberId(회원A.getId()).build()); + + // when + memberAuthCommandService.logout(회원B.getId(), 회원A_리프래쉬_토큰.getId()); + + // then + assertThat(refreshTokenRepository.findById(회원A_리프래쉬_토큰.getId())).isPresent(); + } + } + + @Nested + class refresh { + + @Test + void 기존_리프래쉬_토큰이_있으면_기존_리프래쉬_토큰을_삭제하고_새로운_토큰을_저장한다() { + // given + Member member = memberRepository.save(MemberFixture.builder().build()); + RefreshToken originToken = refreshTokenRepository.save( + RefreshTokenFixture.builder().memberId(member.getId()).build()); + + // when + var actual = memberAuthCommandService.refresh(originToken.getId()); + + // then + assertThat(refreshTokenRepository.findById(originToken.getId())).isEmpty(); + assertThat(refreshTokenRepository.findById(fromString(actual.token()))).isPresent(); + } + + @Test + void 기존_리프래쉬_토큰이_없으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> memberAuthCommandService.refresh(UUID.randomUUID())) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.INVALID_REFRESH_TOKEN.getMessage()); + } + + @Test + void 리프래쉬를_요청한_리프래쉬_토큰이_만료되면_예외가_발생한다() { + // given + Member member = memberRepository.save(MemberFixture.builder().build()); + LocalDateTime yesterday = LocalDateTime.now().minusDays(1); + RefreshToken expiredToken = refreshTokenRepository.save( + RefreshTokenFixture.builder().memberId(member.getId()).expiredAt(yesterday).build()); + + // when & then + assertThatThrownBy(() -> memberAuthCommandService.refresh(expiredToken.getId())) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.EXPIRED_REFRESH_TOKEN.getMessage()); + } + } + + @Nested + class deleteAccount { + + @Test + void 해당_회원이_없으면_예외() { + // when & then + assertThatThrownBy(() -> memberAuthCommandService.deleteAccount(4885L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + void 해당_회원이_있으면_회원이_삭제된다() { + // given + Member member = memberRepository.save(MemberFixture.builder().build()); + + // when + memberAuthCommandService.deleteAccount(member.getId()); + + // then + assertThat(memberRepository.findById(member.getId())).isEmpty(); + } + } + + private UserInfo getUserInfo(String socialId) { + return new UserInfo( + socialId, + SocialType.FESTAGO, + "오리", + "https://image.com/image.png" + ); + } +} diff --git a/backend/src/test/java/com/festago/auth/application/integration/AuthFacadeServiceIntegrationTest.java b/backend/src/test/java/com/festago/auth/application/integration/AuthFacadeServiceIntegrationTest.java deleted file mode 100644 index 4024fe487..000000000 --- a/backend/src/test/java/com/festago/auth/application/integration/AuthFacadeServiceIntegrationTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.festago.auth.application.integration; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.SoftAssertions.assertSoftly; - -import com.festago.application.integration.ApplicationIntegrationTest; -import com.festago.auth.application.AuthFacadeService; -import com.festago.auth.domain.SocialType; -import com.festago.member.domain.Member; -import com.festago.member.repository.MemberRepository; -import com.festago.support.MemberFixture; -import jakarta.persistence.EntityManager; -import java.util.List; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class AuthFacadeServiceIntegrationTest extends ApplicationIntegrationTest { - - @Autowired - MemberRepository memberRepository; - - @Autowired - AuthFacadeService authFacadeService; - - @Autowired - EntityManager entityManager; - - @Test - void 회원이_탈퇴하고_재가입하면_새로운_계정으로_가입() { - // given - authFacadeService.login(SocialType.FESTAGO, "1"); - Member member = memberRepository.findBySocialIdAndSocialType("1", SocialType.FESTAGO).get(); - - // when - memberRepository.delete(member); - authFacadeService.login(SocialType.FESTAGO, "1"); - - // then - assertThat(memberRepository.count()).isEqualTo(1); - } - - @Test - void 회원탈퇴() { - // given - Member member = memberRepository.save(MemberFixture.member().build()); - - // when - authFacadeService.deleteMember(member.getId()); - - // then - String sql = "SELECT * FROM member WHERE id = :memberId AND deleted_at IS NOT NULL"; - List deletedMembers = entityManager.createNativeQuery(sql, Member.class) - .setParameter("memberId", member.getId()) - .getResultList(); - - assertSoftly(softly -> { - softly.assertThat(deletedMembers.size()).isOne(); - Member actual = deletedMembers.get(0); - softly.assertThat(actual.getId()).isEqualTo(member.getId()); - softly.assertThat(actual.getNickname()).isEqualTo("탈퇴한 회원"); - softly.assertThat(actual.getProfileImage()).isBlank(); - }); - } - - - @Test - void 탈퇴한_회원은_조회되지않는다() { - // given - Member member = memberRepository.save(MemberFixture.member().build()); - - // when - authFacadeService.deleteMember(member.getId()); - - // then - assertThat(memberRepository.findById(member.getId())).isEmpty(); - } -} diff --git a/backend/src/test/java/com/festago/auth/config/LoginConfigTest.java b/backend/src/test/java/com/festago/auth/config/LoginConfigTest.java new file mode 100644 index 000000000..ab404062c --- /dev/null +++ b/backend/src/test/java/com/festago/auth/config/LoginConfigTest.java @@ -0,0 +1,68 @@ +package com.festago.auth.config; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.auth.annotation.MemberAuth; +import com.festago.auth.domain.Role; +import com.festago.common.exception.ErrorCode; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class LoginConfigTest { + + @Autowired + MockMvc mockMvc; + + @Nested + class MemberAuth_어노테이션이_붙은_핸들러_메서드는_인증_기능이_수행된다 { + + @Test + @WithMockAuth(role = Role.ANONYMOUS) + void 토큰이_없으면_401_응답이_반환된다() throws Exception { + mockMvc.perform(get("/api/annotation-member-auth")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth + void 토큰이_있으면_200_응답이_반환된다() throws Exception { + mockMvc.perform(get("/api/annotation-member-auth") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()); + } + + @Test + @WithMockAuth(role = Role.ADMIN) + void 토큰의_권한이_어드민이면_404_응답이_반환된다() throws Exception { + mockMvc.perform(get("/api/annotation-member-auth") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(ErrorCode.NOT_ENOUGH_PERMISSION.name())); + } + } +} + +@RestController +class AnnotationMemberAuthController { + + @MemberAuth + @GetMapping("/api/annotation-member-auth") + public ResponseEntity testAuthHandler() { + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/test/java/com/festago/auth/domain/OAuth2ClientsTest.java b/backend/src/test/java/com/festago/auth/domain/OAuth2ClientsTest.java index f1df89a40..dea7fe991 100644 --- a/backend/src/test/java/com/festago/auth/domain/OAuth2ClientsTest.java +++ b/backend/src/test/java/com/festago/auth/domain/OAuth2ClientsTest.java @@ -1,6 +1,5 @@ package com.festago.auth.domain; -import static com.festago.common.exception.ErrorCode.DUPLICATE_SOCIAL_TYPE; import static com.festago.common.exception.ErrorCode.OAUTH2_NOT_SUPPORTED_SOCIAL_TYPE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -9,11 +8,12 @@ import com.festago.auth.application.OAuth2Client; import com.festago.auth.application.OAuth2Clients; import com.festago.auth.application.OAuth2Clients.OAuth2ClientsBuilder; -import com.festago.auth.infrastructure.FestagoOAuth2Client; -import com.festago.auth.infrastructure.KakaoOAuth2Client; -import com.festago.auth.infrastructure.KakaoOAuth2UserInfoClient; +import com.festago.auth.infrastructure.oauth2.FestagoOAuth2Client; +import com.festago.auth.infrastructure.oauth2.KakaoOAuth2AccessTokenClient; +import com.festago.auth.infrastructure.oauth2.KakaoOAuth2Client; +import com.festago.auth.infrastructure.oauth2.KakaoOAuth2UserInfoClient; import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.UnexpectedException; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; @@ -33,8 +33,8 @@ class OAuth2ClientsTest { // when & then assertThatThrownBy(() -> builder.add(new FestagoOAuth2Client())) - .isInstanceOf(InternalServerException.class) - .hasMessage(DUPLICATE_SOCIAL_TYPE.getMessage()); + .isInstanceOf(UnexpectedException.class) + .hasMessage("중복된 OAuth2 제공자 입니다."); } @Test @@ -54,6 +54,7 @@ class OAuth2ClientsTest { // given FestagoOAuth2Client festagoOAuth2Client = new FestagoOAuth2Client(); KakaoOAuth2Client kakaoOAuth2Client = new KakaoOAuth2Client( + mock(KakaoOAuth2AccessTokenClient.class), mock(KakaoOAuth2UserInfoClient.class) ); diff --git a/backend/src/test/java/com/festago/auth/domain/RefreshTokenTest.java b/backend/src/test/java/com/festago/auth/domain/RefreshTokenTest.java new file mode 100644 index 000000000..e84807603 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/domain/RefreshTokenTest.java @@ -0,0 +1,80 @@ +package com.festago.auth.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class RefreshTokenTest { + + @Nested + class isExpired { + + @Test + void 주어진_시간이_만료시간보다_이후이면_참() { + // given + LocalDateTime now = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime future = now.plusSeconds(1); + RefreshToken refreshToken = new RefreshToken(1L, now); + + // when + boolean actual = refreshToken.isExpired(future); + + // then + assertThat(actual).isTrue(); + } + + @ParameterizedTest + @ValueSource(longs = {0, 1}) + void 주어진_시간이_만료시간보다_같거나_이전이면_거짓(long second) { + // given + LocalDateTime now = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime past = now.minusSeconds(second); + RefreshToken refreshToken = new RefreshToken(1L, now); + + // when + boolean actual = refreshToken.isExpired(past); + + // then + assertThat(actual).isFalse(); + } + } + + @Nested + class isOwner { + + @Test + void 주어진_식별자가_자신의_memberId와_같으면_참() { + // given + Long memberId = 1L; + RefreshToken refreshToken = new RefreshToken(memberId, LocalDateTime.now()); + + // when + boolean actual = refreshToken.isOwner(memberId); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 주어진_식별자가_자신의_memberId와_다르면_거짓() { + // given + Long memberId = 1L; + Long otherId = 2L; + RefreshToken refreshToken = new RefreshToken(memberId, LocalDateTime.now()); + + // when + boolean actual = refreshToken.isOwner(otherId); + + // then + assertThat(actual).isFalse(); + } + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/AdminAuthenticationClaimsExtractorTest.java b/backend/src/test/java/com/festago/auth/infrastructure/AdminAuthenticationClaimsExtractorTest.java new file mode 100644 index 000000000..b13765bd1 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/AdminAuthenticationClaimsExtractorTest.java @@ -0,0 +1,52 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.Authentication; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminAuthenticationClaimsExtractorTest { + + AdminAuthenticationClaimsExtractor adminAuthenticationClaimsExtractor = new AdminAuthenticationClaimsExtractor(); + + @ParameterizedTest + @EnumSource(names = "ADMIN", mode = EnumSource.Mode.EXCLUDE) + void Claims의_audience가_ADMIN이_아니면_반환되는_Authentication의_권한은_ANONYMOUS이다(Role role) { + // given + Claims claims = Jwts.claims() + .audience().add(role.name()).and() + .build(); + + // when + Authentication authentication = adminAuthenticationClaimsExtractor.extract(claims); + + // then + assertThat(authentication.getRole()) + .isEqualTo(Role.ANONYMOUS); + } + + @Test + void Claims의_audience가_ADMIN이면_Authentication의_권한은_ADMIN이다() { + // given + Claims claims = Jwts.claims() + .audience().add(Role.ADMIN.name()).and() + .add("adminId", 1) + .build(); + + // when + Authentication authentication = adminAuthenticationClaimsExtractor.extract(claims); + + // then + assertThat(authentication.getRole()) + .isEqualTo(Role.ADMIN); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/CachedOpenIdKeyProviderTest.java b/backend/src/test/java/com/festago/auth/infrastructure/CachedOpenIdKeyProviderTest.java new file mode 100644 index 000000000..c4b6cad68 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/CachedOpenIdKeyProviderTest.java @@ -0,0 +1,112 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.times; +import static org.mockito.BDDMockito.verify; + +import com.festago.auth.infrastructure.openid.CachedOpenIdKeyProvider; +import io.jsonwebtoken.security.JwkSet; +import io.jsonwebtoken.security.Jwks; +import java.security.Key; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class CachedOpenIdKeyProviderTest { + + CachedOpenIdKeyProvider cachedOpenIdKeyProvider; + + String jwksJson = """ + { + "keys": [ + { + "kid": "1", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "q8zZ0b_MNaLd6Ny8wd4cjFomilLfFIZcmhNSc1ttx_oQdJJZt5CDHB8WWwPGBUDUyY8AmfglS9Y1qA0_fxxs-ZUWdt45jSbUxghKNYgEwSutfM5sROh3srm5TiLW4YfOvKytGW1r9TQEdLe98ork8-rNRYPybRI3SKoqpci1m1QOcvUg4xEYRvbZIWku24DNMSeheytKUz6Ni4kKOVkzfGN11rUj1IrlRR-LNA9V9ZYmeoywy3k066rD5TaZHor5bM5gIzt1B4FmUuFITpXKGQZS5Hn_Ck8Bgc8kLWGAU8TzmOzLeROosqKE0eZJ4ESLMImTb2XSEZuN1wFyL0VtJw", + "e": "AQAB" + } + ] + } + """; + + @BeforeEach + void setUp() { + cachedOpenIdKeyProvider = new CachedOpenIdKeyProvider(); + } + + @Test + void kid에_대한_JWK_키가_있으면_null이_아니다() { + // given + JwkSet jwtSet = Jwks.setParser() + .build() + .parse(jwksJson); + + // when + Key actual = cachedOpenIdKeyProvider.provide("1", () -> jwtSet); + + // then + assertThat(actual).isNotNull(); + } + + @Test + void kid에_대한_JWK_키가_없으면_null이다() { + // given + JwkSet jwtSet = Jwks.setParser() + .build() + .parse(jwksJson); + + // when + Key actual = cachedOpenIdKeyProvider.provide("2", () -> jwtSet); + + // then + assertThat(actual).isNull(); + } + + @Test + void kid에_대한_JWK_키가_캐싱되어야_한다() { + // given + JwkSet jwtSet = Jwks.setParser() + .build() + .parse(jwksJson); + Supplier jwkSetSupplier = mock(Supplier.class); + given(jwkSetSupplier.get()) + .willReturn(jwtSet); + cachedOpenIdKeyProvider.provide("1", jwkSetSupplier); + + // when + cachedOpenIdKeyProvider.provide("1", jwkSetSupplier); + + // then + verify(jwkSetSupplier, times(1)).get(); + } + + @Test + void 동시에_요청이_와도_캐시에_값을_갱신하는_로직은_한_번만_호출된다() { + // given + JwkSet jwtSet = Jwks.setParser() + .build() + .parse(jwksJson); + Supplier jwkSetSupplier = mock(Supplier.class); + given(jwkSetSupplier.get()) + .willReturn(jwtSet); + + // when + var futures = IntStream.rangeClosed(1, 10) + .mapToObj(i -> CompletableFuture.runAsync(() -> cachedOpenIdKeyProvider.provide("1", jwkSetSupplier))) + .toList(); + futures.forEach(CompletableFuture::join); + + // then + verify(jwkSetSupplier, times(1)).get(); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/CompositeAuthenticationTokenExtractorTest.java b/backend/src/test/java/com/festago/auth/infrastructure/CompositeAuthenticationTokenExtractorTest.java new file mode 100644 index 000000000..a1c77dc00 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/CompositeAuthenticationTokenExtractorTest.java @@ -0,0 +1,56 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.mock; + +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.AdminAuthentication; +import com.festago.auth.domain.authentication.AnonymousAuthentication; +import com.festago.auth.domain.authentication.Authentication; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class CompositeAuthenticationTokenExtractorTest { + + JwtTokenParser jwtTokenParser; + + @BeforeEach + void setUp() { + jwtTokenParser = mock(); + } + + @Test + void AuthenticationClaimsExtractors_모두_AnonymousAuthentication을_반환하면_권한이_Anonymous인_Authentication을_반환한다() { + // given + CompositeAuthenticationTokenExtractor compositeAuthenticationTokenExtractor = new CompositeAuthenticationTokenExtractor( + jwtTokenParser, + List.of(claims -> AnonymousAuthentication.getInstance(), claims -> AnonymousAuthentication.getInstance()) + ); + + // when + Authentication actual = compositeAuthenticationTokenExtractor.extract("token"); + + // then + assertThat(actual.getRole()).isEqualTo(Role.ANONYMOUS); + } + + @Test + void HttpRequestTokenExtractors_중_하나라도_AnonymousAuthentication이_아닌_값을_반환하면_해당_값을_반환한다() { + // given + CompositeAuthenticationTokenExtractor compositeAuthenticationTokenExtractor = new CompositeAuthenticationTokenExtractor( + jwtTokenParser, + List.of(claims -> AnonymousAuthentication.getInstance(), claims -> new AdminAuthentication(1L)) + ); + + // when + Authentication actual = compositeAuthenticationTokenExtractor.extract("token"); + + // then + assertThat(actual.getRole()).isEqualTo(Role.ADMIN); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/CompositeHttpRequestTokenExtractorTest.java b/backend/src/test/java/com/festago/auth/infrastructure/CompositeHttpRequestTokenExtractorTest.java new file mode 100644 index 000000000..f85cf700c --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/CompositeHttpRequestTokenExtractorTest.java @@ -0,0 +1,52 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.mock; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class CompositeHttpRequestTokenExtractorTest { + + HttpServletRequest request; + + @BeforeEach + void setUp() { + request = mock(HttpServletRequest.class); + } + + @Test + void HttpRequestTokenExtractors_모두_빈_옵셔널을_반환하면_빈_옵셔널을_반환한다() { + // given + CompositeHttpRequestTokenExtractor compositeHttpRequestTokenExtractor = new CompositeHttpRequestTokenExtractor( + List.of(req -> Optional.empty(), req -> Optional.empty()) + ); + + // when + Optional actual = compositeHttpRequestTokenExtractor.extract(request); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void HttpRequestTokenExtractors_중_하나라도_빈_옵셔널이_아닌_값을_반환하면_해당_값을_반환한다() { + // given + CompositeHttpRequestTokenExtractor compositeHttpRequestTokenExtractor = new CompositeHttpRequestTokenExtractor( + List.of(req -> Optional.empty(), req -> Optional.of("present")) + ); + + // when + Optional actual = compositeHttpRequestTokenExtractor.extract(request); + + // then + assertThat(actual).contains("present"); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/CookieHttpRequestTokenExtractorTest.java b/backend/src/test/java/com/festago/auth/infrastructure/CookieHttpRequestTokenExtractorTest.java new file mode 100644 index 000000000..b4eaf33cd --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/CookieHttpRequestTokenExtractorTest.java @@ -0,0 +1,54 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class CookieHttpRequestTokenExtractorTest { + + CookieHttpRequestTokenExtractor cookieTokenExtractor = new CookieHttpRequestTokenExtractor(); + + @Mock + HttpServletRequest request; + + @Test + void 요청에_쿠키가_없으면_empty() { + // given + given(request.getCookies()) + .willReturn(null); + + // when & then + assertThat(cookieTokenExtractor.extract(request)).isEmpty(); + } + + @Test + void 쿠키에_token_헤더가_없으면_empty() { + // given + given(request.getCookies()) + .willReturn(new Cookie[]{new Cookie("tokken", "token")}); + + // when + assertThat(cookieTokenExtractor.extract(request)).isEmpty(); + } + + @Test + void 쿠키에_token_헤더가_있으면_present() { + // given + given(request.getCookies()) + .willReturn(new Cookie[]{new Cookie("token", "token")}); + + // when + assertThat(cookieTokenExtractor.extract(request)).isPresent(); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/CookieTokenExtractorTest.java b/backend/src/test/java/com/festago/auth/infrastructure/CookieTokenExtractorTest.java deleted file mode 100644 index 3cc1b482c..000000000 --- a/backend/src/test/java/com/festago/auth/infrastructure/CookieTokenExtractorTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.festago.auth.infrastructure; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -@ExtendWith(MockitoExtension.class) -class CookieTokenExtractorTest { - - CookieTokenExtractor cookieTokenExtractor = new CookieTokenExtractor(); - - @Mock - HttpServletRequest request; - - @Test - void 요청에_쿠키가_없으면_empty() { - // given - given(request.getCookies()) - .willReturn(null); - - // when & then - assertThat(cookieTokenExtractor.extract(request)).isEmpty(); - } - - @Test - void 쿠키에_token_헤더가_없으면_empty() { - // given - given(request.getCookies()) - .willReturn(new Cookie[]{new Cookie("tokken", "token")}); - - // when - assertThat(cookieTokenExtractor.extract(request)).isEmpty(); - } - - @Test - void 쿠키에_token_헤더가_있으면_present() { - // given - given(request.getCookies()) - .willReturn(new Cookie[]{new Cookie("token", "token")}); - - // when - assertThat(cookieTokenExtractor.extract(request)).isPresent(); - } -} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/HeaderHttpRequestTokenExtractorTest.java b/backend/src/test/java/com/festago/auth/infrastructure/HeaderHttpRequestTokenExtractorTest.java new file mode 100644 index 000000000..6fac009b2 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/HeaderHttpRequestTokenExtractorTest.java @@ -0,0 +1,59 @@ +package com.festago.auth.infrastructure; + +import static com.festago.common.exception.ErrorCode.NOT_BEARER_TOKEN_TYPE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.festago.common.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class HeaderHttpRequestTokenExtractorTest { + + HeaderHttpRequestTokenExtractor headerTokenExtractor = new HeaderHttpRequestTokenExtractor(); + + @Mock + HttpServletRequest request; + + @Test + void 요청에_Authorization_헤더가_없으면_empty() { + // given + given(request.getHeader(HttpHeaders.AUTHORIZATION)) + .willReturn(null); + + // when & then + assertThat(headerTokenExtractor.extract(request)).isEmpty(); + } + + @Test + void Bearer_토큰이_아니면_예외() { + // given + given(request.getHeader(HttpHeaders.AUTHORIZATION)) + .willReturn("Bear sampleToken"); + + // when & then + assertThatThrownBy(() -> headerTokenExtractor.extract(request)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(NOT_BEARER_TOKEN_TYPE.getMessage()); + } + + @Test + void Bearer_토큰이_아니면_present() { + // given + given(request.getHeader(HttpHeaders.AUTHORIZATION)) + .willReturn("Bearer sampleToken"); + + // when & then + assertThat(headerTokenExtractor.extract(request)).isPresent(); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/HeaderTokenExtractorTest.java b/backend/src/test/java/com/festago/auth/infrastructure/HeaderTokenExtractorTest.java deleted file mode 100644 index c6d997676..000000000 --- a/backend/src/test/java/com/festago/auth/infrastructure/HeaderTokenExtractorTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.festago.auth.infrastructure; - -import static com.festago.common.exception.ErrorCode.NOT_BEARER_TOKEN_TYPE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; - -import com.festago.common.exception.UnauthorizedException; -import jakarta.servlet.http.HttpServletRequest; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpHeaders; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -@ExtendWith(MockitoExtension.class) -class HeaderTokenExtractorTest { - - HeaderTokenExtractor headerTokenExtractor = new HeaderTokenExtractor(); - - @Mock - HttpServletRequest request; - - @Test - void 요청에_Authorization_헤더가_없으면_empty() { - // given - given(request.getHeader(HttpHeaders.AUTHORIZATION)) - .willReturn(null); - - // when & then - assertThat(headerTokenExtractor.extract(request)).isEmpty(); - } - - @Test - void Bearer_토큰이_아니면_예외() { - // given - given(request.getHeader(HttpHeaders.AUTHORIZATION)) - .willReturn("Bear sampleToken"); - - // when & then - assertThatThrownBy(() -> headerTokenExtractor.extract(request)) - .isInstanceOf(UnauthorizedException.class) - .hasMessage(NOT_BEARER_TOKEN_TYPE.getMessage()); - } - - @Test - void Bearer_토큰이_아니면_present() { - // given - given(request.getHeader(HttpHeaders.AUTHORIZATION)) - .willReturn("Bearer sampleToken"); - - // when & then - assertThat(headerTokenExtractor.extract(request)).isPresent(); - } -} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/JwtAuthExtractorTest.java b/backend/src/test/java/com/festago/auth/infrastructure/JwtAuthExtractorTest.java deleted file mode 100644 index 2af3d66f8..000000000 --- a/backend/src/test/java/com/festago/auth/infrastructure/JwtAuthExtractorTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.festago.auth.infrastructure; - -import static com.festago.common.exception.ErrorCode.EXPIRED_AUTH_TOKEN; -import static com.festago.common.exception.ErrorCode.INVALID_AUTH_TOKEN; -import static com.festago.common.exception.ErrorCode.INVALID_ROLE_NAME; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.festago.auth.domain.AuthPayload; -import com.festago.auth.domain.Role; -import com.festago.common.exception.InternalServerException; -import com.festago.common.exception.UnauthorizedException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.security.Keys; -import java.nio.charset.StandardCharsets; -import java.security.Key; -import java.util.Date; -import org.assertj.core.api.SoftAssertions; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class JwtAuthExtractorTest { - - private static final String MEMBER_ID_KEY = "memberId"; - private static final String ROLE_ID_KEY = "role"; - private static final String SECRET_KEY = "1231231231231231223131231231231231231212312312"; - private static final Key KEY = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)); - - JwtAuthExtractor jwtAuthExtractor = new JwtAuthExtractor(SECRET_KEY); - - @Test - void JWT_토큰의_형식이_아니면_예외() { - // given - String token = "Hello World"; - - // when & then - assertThatThrownBy(() -> jwtAuthExtractor.extract(token)) - .isInstanceOf(UnauthorizedException.class) - .hasMessage(INVALID_AUTH_TOKEN.getMessage()); - } - - @Test - void 기간이_만료된_토큰이면_예외() { - //given - String token = Jwts.builder() - .claim(MEMBER_ID_KEY, 1L) - .setExpiration(new Date(new Date().getTime() - 1000)) - .signWith(KEY, SignatureAlgorithm.HS256) - .compact(); - - // when & then - assertThatThrownBy(() -> jwtAuthExtractor.extract(token)) - .isInstanceOf(UnauthorizedException.class) - .hasMessage(EXPIRED_AUTH_TOKEN.getMessage()); - } - - @Test - void 키값이_유효하지_않으면_예외() { - // given - Key otherKey = Keys.hmacShaKeyFor(("a" + SECRET_KEY).getBytes(StandardCharsets.UTF_8)); - - String token = Jwts.builder() - .claim(MEMBER_ID_KEY, 1L) - .setExpiration(new Date(new Date().getTime() + 10000)) - .signWith(otherKey, SignatureAlgorithm.HS256) - .compact(); - - // when & then - assertThatThrownBy(() -> jwtAuthExtractor.extract(token)) - .isInstanceOf(UnauthorizedException.class) - .hasMessage(INVALID_AUTH_TOKEN.getMessage()); - } - - @Test - void role_필드가_없으면_예외() { - // given - String token = Jwts.builder() - .claim(MEMBER_ID_KEY, 1) - .setExpiration(new Date(new Date().getTime() + 10000)) - .signWith(KEY, SignatureAlgorithm.HS256) - .compact(); - - // when & then - assertThatThrownBy(() -> jwtAuthExtractor.extract(token)) - .isInstanceOf(InternalServerException.class) - .hasMessage(INVALID_ROLE_NAME.getMessage()); - } - - @Test - void token이_null이면_예외() { - // when & then - assertThatThrownBy(() -> jwtAuthExtractor.extract(null)) - .isInstanceOf(UnauthorizedException.class) - .hasMessage(INVALID_AUTH_TOKEN.getMessage()); - } - - @Test - void 토큰_추출_성공() { - // given - Long memberId = 1L; - String token = Jwts.builder() - .claim(MEMBER_ID_KEY, memberId) - .claim(ROLE_ID_KEY, Role.MEMBER) - .setExpiration(new Date(new Date().getTime() + 10000)) - .signWith(KEY, SignatureAlgorithm.HS256) - .compact(); - - // when - AuthPayload payload = jwtAuthExtractor.extract(token); - - // then - SoftAssertions.assertSoftly(softly -> { - softly.assertThat(payload.getMemberId()).isEqualTo(memberId); - softly.assertThat(payload.getRole()).isEqualTo(Role.MEMBER); - }); - } -} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/JwtAuthProviderTest.java b/backend/src/test/java/com/festago/auth/infrastructure/JwtAuthProviderTest.java deleted file mode 100644 index aabcf2d22..000000000 --- a/backend/src/test/java/com/festago/auth/infrastructure/JwtAuthProviderTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.festago.auth.infrastructure; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.festago.auth.domain.AuthPayload; -import com.festago.auth.domain.Role; -import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.Jwts; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class JwtAuthProviderTest { - - private static final String SECRET_KEY = "1231231231231231223131231231231231231212312312"; - JwtAuthProvider jwtAuthProvider = new JwtAuthProvider(SECRET_KEY, 360); - - @Test - void 토큰_생성_성공() { - // given - AuthPayload authPayload = new AuthPayload(1L, Role.MEMBER); - JwtParser parser = Jwts.parserBuilder() - .setSigningKey(SECRET_KEY.getBytes()) - .build(); - - // when - String token = jwtAuthProvider.provide(authPayload); - - // then - assertThat(parser.isSigned(token)) - .isTrue(); - } -} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/JwtTokenParserTest.java b/backend/src/test/java/com/festago/auth/infrastructure/JwtTokenParserTest.java new file mode 100644 index 000000000..21bbafea8 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/JwtTokenParserTest.java @@ -0,0 +1,106 @@ +package com.festago.auth.infrastructure; + +import static com.festago.common.exception.ErrorCode.EXPIRED_AUTH_TOKEN; +import static com.festago.common.exception.ErrorCode.INVALID_AUTH_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.auth.domain.Role; +import com.festago.common.exception.UnauthorizedException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Clock; +import java.util.Date; +import javax.crypto.SecretKey; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class JwtTokenParserTest { + + private static final String KEY = "1231231231231231223131231231231231231212312312"; + private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor(KEY.getBytes(StandardCharsets.UTF_8)); + + JwtTokenParser jwtTokenParser; + + @BeforeEach + void setUp() { + jwtTokenParser = new JwtTokenParser( + KEY, + Clock.systemDefaultZone() + ); + } + + @Test + void JWT_토큰의_형식이_아니면_예외() { + // given + String token = "Hello World"; + + // when & then + assertThatThrownBy(() -> jwtTokenParser.getClaims(token)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(INVALID_AUTH_TOKEN.getMessage()); + } + + @Test + void 기간이_만료된_토큰이면_예외() { + //given + String token = Jwts.builder() + .audience().add(Role.MEMBER.name()).and() + .expiration(new Date(new Date().getTime() - 1000)) + .signWith(SECRET_KEY) + .compact(); + + // when & then + assertThatThrownBy(() -> jwtTokenParser.getClaims(token)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(EXPIRED_AUTH_TOKEN.getMessage()); + } + + @Test + void 키값이_유효하지_않으면_예외() { + // given + Key otherKey = Keys.hmacShaKeyFor(("a" + SECRET_KEY).getBytes(StandardCharsets.UTF_8)); + + String token = Jwts.builder() + .audience().add(Role.MEMBER.name()).and() + .expiration(new Date(new Date().getTime() + 10000)) + .signWith(otherKey) + .compact(); + + // when & then + assertThatThrownBy(() -> jwtTokenParser.getClaims(token)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(INVALID_AUTH_TOKEN.getMessage()); + } + + @Test + void token이_null이면_예외() { + // when & then + assertThatThrownBy(() -> jwtTokenParser.getClaims(null)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(INVALID_AUTH_TOKEN.getMessage()); + } + + @Test + void 토큰_추출_성공() { + // given + String token = Jwts.builder() + .audience().add(Role.MEMBER.name()).and() + .expiration(new Date(new Date().getTime() + 10000)) + .signWith(SECRET_KEY) + .compact(); + + // when + Claims claims = jwtTokenParser.getClaims(token); + + // then + assertThat(claims.getAudience()).containsOnly(Role.MEMBER.name()); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/KakaoOAuth2AccessTokenClientTest.java b/backend/src/test/java/com/festago/auth/infrastructure/KakaoOAuth2AccessTokenClientTest.java new file mode 100644 index 000000000..fd9b99077 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/KakaoOAuth2AccessTokenClientTest.java @@ -0,0 +1,112 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.auth.dto.KakaoAccessTokenResponse; +import com.festago.auth.infrastructure.oauth2.KakaoOAuth2AccessTokenClient; +import com.festago.auth.infrastructure.oauth2.KakaoOAuth2AccessTokenErrorHandler.KakaoOAuth2ErrorResponse; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.response.MockRestResponseCreators; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@RestClientTest(KakaoOAuth2AccessTokenClient.class) +class KakaoOAuth2AccessTokenClientTest { + + private static final String URL = "https://kauth.kakao.com/oauth/token"; + + @Autowired + KakaoOAuth2AccessTokenClient kakaoOAuth2AccessTokenClient; + + @Autowired + MockRestServiceServer mockServer; + + @Autowired + ObjectMapper objectMapper; + + @Test + void 상태코드_400에서_KOE320_에러코드_이면_BadRequest_예외() throws JsonProcessingException { + // given + KakaoOAuth2ErrorResponse expected = new KakaoOAuth2ErrorResponse("error", "description", "KOE320"); + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withBadRequest() + .contentType(MediaType.APPLICATION_JSON) + .body(objectMapper.writeValueAsString(expected))); + + // when & then + assertThatThrownBy(() -> kakaoOAuth2AccessTokenClient.getAccessToken("code")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.OAUTH2_INVALID_CODE.getMessage()); + } + + @Test + void 상태코드_400에서_KOE320_에러코드가_아니면_InternalServer_예외() throws JsonProcessingException { + // given + KakaoOAuth2ErrorResponse expected = new KakaoOAuth2ErrorResponse("error", "description", "any"); + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withBadRequest() + .contentType(MediaType.APPLICATION_JSON) + .body(objectMapper.writeValueAsString(expected))); + + // when & then + assertThatThrownBy(() -> kakaoOAuth2AccessTokenClient.getAccessToken("code")) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.OAUTH2_INVALID_REQUEST.getMessage()); + } + + @Test + void 상태코드가_401이면_InternalServer_예외() { + // given + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withUnauthorizedRequest() + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatThrownBy(() -> kakaoOAuth2AccessTokenClient.getAccessToken("code")) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.OAUTH2_INVALID_REQUEST.getMessage()); + } + + @Test + void 상태코드가_500이면_InternalServer_예외() { + // given + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withStatus(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatThrownBy(() -> kakaoOAuth2AccessTokenClient.getAccessToken("code")) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.OAUTH2_PROVIDER_NOT_RESPONSE.getMessage()); + } + + @Test + void 성공() throws JsonProcessingException { + // given + KakaoAccessTokenResponse expected = new KakaoAccessTokenResponse("tokenType", "accessToken", + 100, "refreshToken", 50); + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withSuccess() + .contentType(MediaType.APPLICATION_JSON) + .body(objectMapper.writeValueAsString(expected))); + // when + String actual = kakaoOAuth2AccessTokenClient.getAccessToken("code"); + + // then + assertThat(actual).isEqualTo(expected.accessToken()); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoClientTest.java b/backend/src/test/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoClientTest.java index 12958a788..73b459a5e 100644 --- a/backend/src/test/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoClientTest.java +++ b/backend/src/test/java/com/festago/auth/infrastructure/KakaoOAuth2UserInfoClientTest.java @@ -13,6 +13,7 @@ import com.festago.auth.dto.KakaoUserInfo; import com.festago.auth.dto.KakaoUserInfo.KakaoAccount; import com.festago.auth.dto.KakaoUserInfo.KakaoAccount.Profile; +import com.festago.auth.infrastructure.oauth2.KakaoOAuth2UserInfoClient; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.InternalServerException; import org.junit.jupiter.api.DisplayNameGeneration; diff --git a/backend/src/test/java/com/festago/auth/infrastructure/KakaoOpenIdClientTest.java b/backend/src/test/java/com/festago/auth/infrastructure/KakaoOpenIdClientTest.java new file mode 100644 index 000000000..b19c4a98f --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/KakaoOpenIdClientTest.java @@ -0,0 +1,194 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +import com.festago.auth.infrastructure.openid.KakaoOpenIdClient; +import com.festago.auth.infrastructure.openid.KakaoOpenIdPublicKeyLocator; +import com.festago.auth.infrastructure.openid.NoopOpenIdNonceValidator; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Clock; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class KakaoOpenIdClientTest { + + KakaoOpenIdClient kakaoOpenIdClient; + + KakaoOpenIdPublicKeyLocator keyLocator; + + Clock clock; + + Key key = Keys.hmacShaKeyFor("key".repeat(15).getBytes(StandardCharsets.UTF_8)); + + @BeforeEach + void setUp() { + keyLocator = mock(); + clock = spy(Clock.systemDefaultZone()); + kakaoOpenIdClient = new KakaoOpenIdClient( + "restApiKey", + "nativeAppKey", + keyLocator, + new NoopOpenIdNonceValidator(), + clock + ); + } + + @Test + void audience가_올바르지_않으면_예외() { + // given + given(keyLocator.locate(any())) + .willReturn(key); + String idToken = Jwts.builder() + .audience().add("wrong") + .and() + .issuer("https://kauth.kakao.com") + .signWith(key) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when & then + assertThatThrownBy(() -> kakaoOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void issuer가_올바르지_않으면_예외() { + // given + given(keyLocator.locate(any())) + .willReturn(key); + String idToken = Jwts.builder() + .audience().add("client-id") + .and() + .issuer("wrong") + .signWith(key) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when & then + assertThatThrownBy(() -> kakaoOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void 토큰이_만료되면_예외() { + // given + given(keyLocator.locate(any())) + .willReturn(key); + Date yesterday = Date.from(clock.instant().minus(1, ChronoUnit.DAYS)); + String idToken = Jwts.builder() + .audience().add("client-id") + .and() + .issuer("https://kauth.kakao.com") + .signWith(key) + .expiration(yesterday) + .compact(); + + // when & then + assertThatThrownBy(() -> kakaoOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void 토큰에_서명된_키가_파싱할때_키와_일치하지_않으면_예외() { + // given + Key otherKey = Keys.hmacShaKeyFor("otherKey".repeat(10).getBytes(StandardCharsets.UTF_8)); + given(keyLocator.locate(any())) + .willReturn(otherKey); + String idToken = Jwts.builder() + .audience().add("client-id") + .and() + .issuer("https://kauth.kakao.com") + .signWith(key) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when & then + assertThatThrownBy(() -> kakaoOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void 파싱할때_키가_null이면_예외() { + // given + given(keyLocator.locate(any())) + .willReturn(null); + String idToken = Jwts.builder() + .audience().add("client-id") + .and() + .issuer("https://kauth.kakao.com") + .signWith(key) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when & then + assertThatThrownBy(() -> kakaoOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void audience_issuer가_올바르면_성공() { + // given + String socialId = "12345"; + given(keyLocator.locate(any())) + .willReturn(key); + String idToken = Jwts.builder() + .audience().add("restApiKey") + .and() + .issuer("https://kauth.kakao.com") + .signWith(key) + .subject(socialId) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when + var expect = kakaoOpenIdClient.getUserInfo(idToken); + + // then + assertThat(expect.socialId()).isEqualTo(socialId); + } + + @ParameterizedTest + @ValueSource(strings = {"restApiKey", "nativeAppKey"}) + void audience_값은_restApiKey_nativeAppKey_둘_중_하나라도_매칭되면_성공(String audience) { + // given + String socialId = "12345"; + given(keyLocator.locate(any())) + .willReturn(key); + String idToken = Jwts.builder() + .audience().add(audience) + .and() + .issuer("https://kauth.kakao.com") + .signWith(key) + .subject(socialId) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when + var expect = kakaoOpenIdClient.getUserInfo(idToken); + + // then + assertThat(expect.socialId()).isEqualTo(socialId); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/KakaoOpenIdJwksClientTest.java b/backend/src/test/java/com/festago/auth/infrastructure/KakaoOpenIdJwksClientTest.java new file mode 100644 index 000000000..58db2a900 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/KakaoOpenIdJwksClientTest.java @@ -0,0 +1,102 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.auth.infrastructure.openid.KakaoOpenIdJwksClient; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import io.jsonwebtoken.Identifiable; +import io.jsonwebtoken.security.JwkSet; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.response.MockRestResponseCreators; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@RestClientTest(KakaoOpenIdJwksClient.class) +class KakaoOpenIdJwksClientTest { + + private static final String URL = "https://kauth.kakao.com/.well-known/jwks.json"; + + @Autowired + KakaoOpenIdJwksClient kakaoOpenIdJwksClient; + + @Autowired + MockRestServiceServer mockServer; + + @Autowired + ObjectMapper objectMapper; + + @Test + void 상태코드가_4xx이면_InternalServer_예외() { + // given + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withBadRequest() + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatThrownBy(() -> kakaoOpenIdJwksClient.requestGetJwks()) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE.getMessage()); + } + + @Test + void 상태코드가_5xx이면_InternalServer_예외() { + // given + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withServerError() + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatThrownBy(() -> kakaoOpenIdJwksClient.requestGetJwks()) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE.getMessage()); + } + + @Test + void 성공() { + // given + String jwksJson = """ + { + "keys": [ + { + "kid": "3f96980381e451efad0d2ddd30e3d3", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "q8zZ0b_MNaLd6Ny8wd4cjFomilLfFIZcmhNSc1ttx_oQdJJZt5CDHB8WWwPGBUDUyY8AmfglS9Y1qA0_fxxs-ZUWdt45jSbUxghKNYgEwSutfM5sROh3srm5TiLW4YfOvKytGW1r9TQEdLe98ork8-rNRYPybRI3SKoqpci1m1QOcvUg4xEYRvbZIWku24DNMSeheytKUz6Ni4kKOVkzfGN11rUj1IrlRR-LNA9V9ZYmeoywy3k066rD5TaZHor5bM5gIzt1B4FmUuFITpXKGQZS5Hn_Ck8Bgc8kLWGAU8TzmOzLeROosqKE0eZJ4ESLMImTb2XSEZuN1wFyL0VtJw", + "e": "AQAB" + }, + { + "kid": "9f252dadd5f233f93d2fa528d12fea", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "qGWf6RVzV2pM8YqJ6by5exoixIlTvdXDfYj2v7E6xkoYmesAjp_1IYL7rzhpUYqIkWX0P4wOwAsg-Ud8PcMHggfwUNPOcqgSk1hAIHr63zSlG8xatQb17q9LrWny2HWkUVEU30PxxHsLcuzmfhbRx8kOrNfJEirIuqSyWF_OBHeEgBgYjydd_c8vPo7IiH-pijZn4ZouPsEg7wtdIX3-0ZcXXDbFkaDaqClfqmVCLNBhg3DKYDQOoyWXrpFKUXUFuk2FTCqWaQJ0GniO4p_ppkYIf4zhlwUYfXZEhm8cBo6H2EgukntDbTgnoha8kNunTPekxWTDhE5wGAt6YpT4Yw", + "e": "AQAB" + } + ] + } + """; + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withSuccess() + .body(jwksJson) + .contentType(MediaType.APPLICATION_JSON)); + + // when + JwkSet actual = kakaoOpenIdJwksClient.requestGetJwks(); + + // then + assertThat(actual.getKeys()) + .map(Identifiable::getId) + .containsExactlyInAnyOrder("3f96980381e451efad0d2ddd30e3d3", "9f252dadd5f233f93d2fa528d12fea"); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/MemberAuthenticationClaimsExtractorTest.java b/backend/src/test/java/com/festago/auth/infrastructure/MemberAuthenticationClaimsExtractorTest.java new file mode 100644 index 000000000..26e066f69 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/MemberAuthenticationClaimsExtractorTest.java @@ -0,0 +1,52 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.auth.domain.Role; +import com.festago.auth.domain.authentication.Authentication; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberAuthenticationClaimsExtractorTest { + + MemberAuthenticationClaimsExtractor memberAuthenticationClaimsExtractor = new MemberAuthenticationClaimsExtractor(); + + @ParameterizedTest + @EnumSource(names = "MEMBER", mode = EnumSource.Mode.EXCLUDE) + void Claims의_audience가_MEMBER가_아니면_반환되는_Authentication의_권한은_ANONYMOUS이다(Role role) { + // given + Claims claims = Jwts.claims() + .audience().add(role.name()).and() + .build(); + + // when + Authentication authentication = memberAuthenticationClaimsExtractor.extract(claims); + + // then + assertThat(authentication.getRole()) + .isEqualTo(Role.ANONYMOUS); + } + + @Test + void Claims의_audience가_MEMBER이면_Authentication의_권한은_MEMBER이다() { + // given + Claims claims = Jwts.claims() + .audience().add(Role.MEMBER.name()).and() + .add("memberId", 1) + .build(); + + // when + Authentication authentication = memberAuthenticationClaimsExtractor.extract(claims); + + // then + assertThat(authentication.getRole()) + .isEqualTo(Role.MEMBER); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/TokenProviderTemplateTest.java b/backend/src/test/java/com/festago/auth/infrastructure/TokenProviderTemplateTest.java new file mode 100644 index 000000000..93d8dfff5 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/TokenProviderTemplateTest.java @@ -0,0 +1,46 @@ +package com.festago.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.auth.dto.v1.TokenResponse; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class TokenProviderTemplateTest { + + private static final String SECRET_KEY = "1231231231231231223131231231231231231212312312"; + + TokenProviderTemplate tokenProviderTemplate; + + @BeforeEach + void setUp() { + tokenProviderTemplate = new TokenProviderTemplate( + SECRET_KEY, + Clock.systemDefaultZone() + ); + } + + @Test + void 토큰_생성_성공() { + // given + JwtParser parser = Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8))) + .build(); + + // when + TokenResponse response = tokenProviderTemplate.provide(60, jwtBuilder -> jwtBuilder); + + // then + assertThat(parser.isSigned(response.token())) + .isTrue(); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/openid/AppleOpenIdClientTest.java b/backend/src/test/java/com/festago/auth/infrastructure/openid/AppleOpenIdClientTest.java new file mode 100644 index 000000000..a46105aeb --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/openid/AppleOpenIdClientTest.java @@ -0,0 +1,188 @@ +package com.festago.auth.infrastructure.openid; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Clock; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AppleOpenIdClientTest { + + AppleOpenIdClient appleOpenIdClient; + + AppleOpenIdPublicKeyLocator keyLocator; + + Clock clock; + + Key key = Keys.hmacShaKeyFor("key".repeat(15).getBytes(StandardCharsets.UTF_8)); + + @BeforeEach + void setUp() { + keyLocator = mock(); + clock = spy(Clock.systemDefaultZone()); + appleOpenIdClient = new AppleOpenIdClient( + "appleClientId", + keyLocator, + new NoopOpenIdNonceValidator(), + clock + ); + } + + @Test + void audience가_올바르지_않으면_예외() { + // given + given(keyLocator.locate(any())) + .willReturn(key); + String idToken = Jwts.builder() + .audience().add("wrong") + .and() + .issuer("https://appleid.apple.com") + .signWith(key) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when & then + assertThatThrownBy(() -> appleOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void issuer가_올바르지_않으면_예외() { + // given + given(keyLocator.locate(any())) + .willReturn(key); + String idToken = Jwts.builder() + .audience().add("client-id") + .and() + .issuer("wrong") + .signWith(key) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when & then + assertThatThrownBy(() -> appleOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void 토큰이_만료되면_예외() { + // given + given(keyLocator.locate(any())) + .willReturn(key); + Date yesterday = Date.from(clock.instant().minus(1, ChronoUnit.DAYS)); + String idToken = Jwts.builder() + .audience().add("client-id") + .and() + .issuer("https://appleid.apple.com") + .signWith(key) + .expiration(yesterday) + .compact(); + + // when & then + assertThatThrownBy(() -> appleOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void 토큰에_서명된_키가_파싱할때_키와_일치하지_않으면_예외() { + // given + Key otherKey = Keys.hmacShaKeyFor("otherKey".repeat(10).getBytes(StandardCharsets.UTF_8)); + given(keyLocator.locate(any())) + .willReturn(otherKey); + String idToken = Jwts.builder() + .audience().add("client-id") + .and() + .issuer("https://appleid.apple.com") + .signWith(key) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when & then + assertThatThrownBy(() -> appleOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void 파싱할때_키가_null이면_예외() { + // given + given(keyLocator.locate(any())) + .willReturn(null); + String idToken = Jwts.builder() + .audience().add("client-id") + .and() + .issuer("https://appleid.apple.com") + .signWith(key) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when & then + assertThatThrownBy(() -> appleOpenIdClient.getUserInfo(idToken)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.OPEN_ID_INVALID_TOKEN.getMessage()); + } + + @Test + void audience_issuer가_올바르면_성공() { + // given + String socialId = "12345"; + given(keyLocator.locate(any())) + .willReturn(key); + String idToken = Jwts.builder() + .audience().add("appleClientId") + .and() + .issuer("https://appleid.apple.com") + .signWith(key) + .subject(socialId) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when + var expect = appleOpenIdClient.getUserInfo(idToken); + + // then + assertThat(expect.socialId()).isEqualTo(socialId); + } + + @Test + void audience_값은_apple_client_id_와_같으면_성공() { + // given + String socialId = "12345"; + given(keyLocator.locate(any())) + .willReturn(key); + String idToken = Jwts.builder() + .audience().add("appleClientId") + .and() + .issuer("https://appleid.apple.com") + .signWith(key) + .subject(socialId) + .expiration(Date.from(clock.instant().plus(1, ChronoUnit.DAYS))) + .compact(); + + // when + var expect = appleOpenIdClient.getUserInfo(idToken); + + // then + assertThat(expect.socialId()).isEqualTo(socialId); + } +} diff --git a/backend/src/test/java/com/festago/auth/infrastructure/openid/AppleOpenIdJwksClientTest.java b/backend/src/test/java/com/festago/auth/infrastructure/openid/AppleOpenIdJwksClientTest.java new file mode 100644 index 000000000..72afd648a --- /dev/null +++ b/backend/src/test/java/com/festago/auth/infrastructure/openid/AppleOpenIdJwksClientTest.java @@ -0,0 +1,109 @@ +package com.festago.auth.infrastructure.openid; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import io.jsonwebtoken.Identifiable; +import io.jsonwebtoken.security.JwkSet; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.response.MockRestResponseCreators; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@RestClientTest(AppleOpenIdJwksClient.class) +class AppleOpenIdJwksClientTest { + + private static final String URL = "https://appleid.apple.com/auth/keys"; + + @Autowired + AppleOpenIdJwksClient appleOpenIdJwksClient; + + @Autowired + MockRestServiceServer mockServer; + + @Autowired + ObjectMapper objectMapper; + + @Test + void 상태코드가_4xx이면_InternalServer_예외() { + // given + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withBadRequest() + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatThrownBy(() -> appleOpenIdJwksClient.requestGetJwks()) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE.getMessage()); + } + + @Test + void 상태코드가_5xx이면_InternalServer_예외() { + // given + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withServerError() + .contentType(MediaType.APPLICATION_JSON)); + + // when & then + assertThatThrownBy(() -> appleOpenIdJwksClient.requestGetJwks()) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE.getMessage()); + } + + @Test + void 성공() { + // given + String jwksJson = """ + { + "keys": [ + { + "kty": "RSA", + "kid": "pyaRQpAbnY", + "use": "sig", + "alg": "RS256", + "n": "qHiwOpizi6xHG8FIOSWH4l0P1CjLIC7aBFkhbk7BrD4s9KQAs5Sj5xAtOwlZMyP2XFcqRtZBLIMM7vw_CNERtRrhc68se5hQE_vsrHy7ugcQU6ogJS6s54zqO-zTUfaa3mABM6iR-EfgSpvz33WTQZAPtwAyxaSLknHyDzWjHEZ44WqaQBdcMAvgsWMYG5dBfnV-3Or3V2r1vdbinRE5NomE2nsKDbnJ3yo3u-x9TizKazS1JV3umt71xDqbruZLybIrimrzg_i9OSIzT2o5ZWz8zdYkKHZ4cvRPh-DDt8kV7chzR2tenPF2c5WXuK-FumOrjT7WW6uwSvhnhwNZuw", + "e": "AQAB" + }, + { + "kty": "RSA", + "kid": "lVHdOx8ltR", + "use": "sig", + "alg": "RS256", + "n": "nXDu9MPf6dmVtFbDdAaal_0cO9ur2tqrrmCZaAe8TUWHU8AprhJG4DaQoCIa4UsOSCbCYOjPpPGGdE_p0XeP1ew55pBIquNhNtNNEMX0jNYAKcA9WAP1zGSkvH5m39GMFc4SsGiQ_8Szht9cayJX1SJALEgSyDOFLs-ekHnexqsr-KPtlYciwer5jaNcW3B7f9VNp1XCypQloQwSGVismPHwDJowPQ1xOWmhBLCK50NV38ZjobUDSBbCeLYecMtsdL5ZGv-iufddBh3RHszQiD2G-VXoGOs1yE33K4uAto2F2bHVcKOUy0__9qEsXZGf-B5ZOFucUkoN7T2iqu2E2Q", + "e": "AQAB" + }, + { + "kty": "RSA", + "kid": "Bh6H7rHVmb", + "use": "sig", + "alg": "RS256", + "n": "2HkIZ7xKMUYH_wtt2Gwq6jXKRl-Ng5vdwd-XcWn5RIW82-uxdmGJyTo3T6MPty-xWUeW7FCs9NlM4yu02GKgwep7TKfnOovP78sd3rESbZsvuN7zD_Vk6aZP7QfqblElUtiMQxh9bu-gZUeMZfa_ndX-P5C4yKtZvXGrSPLLjyAcSmSHNLZnWbZXjeIVsgXWHMr5fwVEAkftHq_4py82xgn2XEK0FK9HmWOCZ47Wcx9fWBnqSi9JTJTUX0lh-kI5TcYfv9UKX2oe3uyOn-A460E_L_4ximlM-lgi3otw26EZfAGY9FFgSZoACjhgw_z5NRbK9dycHRpeLY9GxIyiYw", + "e": "AQAB" + } + ] + } + """; + mockServer.expect(requestTo(URL)) + .andRespond(MockRestResponseCreators.withSuccess() + .body(jwksJson) + .contentType(MediaType.APPLICATION_JSON)); + + // when + JwkSet actual = appleOpenIdJwksClient.requestGetJwks(); + + // then + assertThat(actual.getKeys()) + .map(Identifiable::getId) + .containsExactlyInAnyOrder("pyaRQpAbnY", "lVHdOx8ltR", "Bh6H7rHVmb"); + } +} diff --git a/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java b/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java deleted file mode 100644 index 6b9cf1786..000000000 --- a/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.festago.auth.presentation; - -import static org.assertj.core.api.Assertions.assertThat; -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.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.festago.auth.application.AuthFacadeService; -import com.festago.auth.domain.Role; -import com.festago.auth.domain.SocialType; -import com.festago.auth.dto.LoginRequest; -import com.festago.auth.dto.LoginResponse; -import com.festago.fcm.application.MemberFCMService; -import com.festago.presentation.AuthController; -import com.festago.support.CustomWebMvcTest; -import com.festago.support.WithMockAuth; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; - -@CustomWebMvcTest(AuthController.class) -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class AuthControllerTest { - - @Autowired - MockMvc mockMvc; - - @Autowired - ObjectMapper objectMapper; - - @MockBean - AuthFacadeService authFacadeService; - - @MockBean - MemberFCMService memberFCMService; - - @Test - void OAuth2_로그인을_한다() throws Exception { - // given - LoginResponse expected = new LoginResponse("accesstoken", "nickname", true); - given(authFacadeService.login(any(), any())) - .willReturn(expected); - LoginRequest request = new LoginRequest(SocialType.FESTAGO, "code", "fcmToken"); - - // when & then - String response = mockMvc.perform(post("/auth/oauth2") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(); - LoginResponse actual = objectMapper.readValue(response, LoginResponse.class); - - assertThat(actual).isEqualTo(expected); - } - - @Test - void 로그인을_하지_않고_탈퇴를_하면_예외() throws Exception { - mockMvc.perform(delete("/auth") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 멤버_권한이_아닌데_탈퇴하면_예외() throws Exception { - mockMvc.perform(delete("/auth") - .header("Authorization", "Bearer token") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } - - @Test - @WithMockAuth - void 회원_탈퇴를_한다() throws Exception { - mockMvc.perform(delete("/auth") - .header("Authorization", "Bearer token") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()); - } - - @Test - void 회원가입_시_유저의_FCM_를_저장한다() throws Exception { - // given - String fcmToken = "fcmToken"; - String accessToken = "accessToken"; - boolean isNewMember = true; - LoginResponse expected = new LoginResponse(accessToken, "nickname", isNewMember); - given(authFacadeService.login(any(), any())) - .willReturn(expected); - LoginRequest request = new LoginRequest(SocialType.FESTAGO, "code", fcmToken); - - // when & then - String response = mockMvc.perform(post("/auth/oauth2") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(); - LoginResponse actual = objectMapper.readValue(response, LoginResponse.class); - - assertThat(actual).isEqualTo(expected); - verify(memberFCMService, times(1)) - .saveMemberFCM(isNewMember, accessToken, fcmToken); - } - - @Test - void 로그인_시_유저의_FCM_를_저장한다() throws Exception { - // given - String fcmToken = "fcmToken"; - String accessToken = "accessToken"; - boolean isNewMember = false; - LoginResponse expected = new LoginResponse(accessToken, "nickname", isNewMember); - given(authFacadeService.login(any(), any())) - .willReturn(expected); - LoginRequest request = new LoginRequest(SocialType.FESTAGO, "code", fcmToken); - - // when & then - String response = mockMvc.perform(post("/auth/oauth2") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(); - LoginResponse actual = objectMapper.readValue(response, LoginResponse.class); - - assertThat(actual).isEqualTo(expected); - verify(memberFCMService, times(1)) - .saveMemberFCM(isNewMember, accessToken, fcmToken); - } -} diff --git a/backend/src/test/java/com/festago/auth/presentation/RoleArgumentResolverTest.java b/backend/src/test/java/com/festago/auth/presentation/RoleArgumentResolverTest.java deleted file mode 100644 index c1b1b2514..000000000 --- a/backend/src/test/java/com/festago/auth/presentation/RoleArgumentResolverTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.festago.auth.presentation; - -import static com.festago.common.exception.ErrorCode.NOT_ENOUGH_PERMISSION; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.festago.auth.domain.Role; -import com.festago.common.exception.ForbiddenException; -import com.festago.presentation.auth.AuthenticateContext; -import com.festago.presentation.auth.RoleArgumentResolver; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class RoleArgumentResolverTest { - - AuthenticateContext authenticateContext; - - RoleArgumentResolver roleArgumentResolver; - - @BeforeEach - void setUp() { - authenticateContext = new AuthenticateContext(); - } - - @ParameterizedTest - @ValueSource(strings = {"ADMIN", "ANONYMOUS"}) - void Role이_Member일때_Member가_아니면_예외(Role role) { - // given - roleArgumentResolver = new RoleArgumentResolver(Role.MEMBER, authenticateContext); - authenticateContext.setAuthenticate(1L, role); - - // when & then - assertThatThrownBy(() -> roleArgumentResolver.resolveArgument(null, null, null, null)) - .isInstanceOf(ForbiddenException.class) - .hasMessage(NOT_ENOUGH_PERMISSION.getMessage()); - } - - @Test - void Role이_Member일때_Member이면_성공() throws Exception { - // given - roleArgumentResolver = new RoleArgumentResolver(Role.MEMBER, authenticateContext); - authenticateContext.setAuthenticate(1L, Role.MEMBER); - - // when - Long memberId = roleArgumentResolver.resolveArgument(null, null, null, null); - - // then - assertThat(memberId).isEqualTo(authenticateContext.getId()); - } - - @ParameterizedTest - @ValueSource(strings = {"MEMBER", "ANONYMOUS"}) - void Role이_Admin일때_Admin이_아니면_예외(Role role) { - // given - roleArgumentResolver = new RoleArgumentResolver(Role.ADMIN, authenticateContext); - authenticateContext.setAuthenticate(1L, role); - - // when & then - assertThatThrownBy(() -> roleArgumentResolver.resolveArgument(null, null, null, null)) - .isInstanceOf(ForbiddenException.class) - .hasMessage(NOT_ENOUGH_PERMISSION.getMessage()); - } - - @Test - void Role이_Admin일때_Admin이면_성공() throws Exception { - // given - roleArgumentResolver = new RoleArgumentResolver(Role.ADMIN, authenticateContext); - authenticateContext.setAuthenticate(1L, Role.ADMIN); - - // when - Long memberId = roleArgumentResolver.resolveArgument(null, null, null, null); - - // then - assertThat(memberId).isEqualTo(authenticateContext.getId()); - } -} diff --git a/backend/src/test/java/com/festago/auth/presentation/v1/AdminAuthV1ControllerTest.java b/backend/src/test/java/com/festago/auth/presentation/v1/AdminAuthV1ControllerTest.java new file mode 100644 index 000000000..2b3bec4f5 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/presentation/v1/AdminAuthV1ControllerTest.java @@ -0,0 +1,194 @@ +package com.festago.auth.presentation.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.auth.application.command.AdminAuthCommandService; +import com.festago.auth.domain.AuthType; +import com.festago.auth.domain.Role; +import com.festago.auth.dto.AdminLoginV1Request; +import com.festago.auth.dto.AdminSignupV1Request; +import com.festago.auth.dto.RootAdminInitializeRequest; +import com.festago.auth.dto.command.AdminLoginCommand; +import com.festago.auth.dto.command.AdminLoginResult; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminAuthV1ControllerTest { + + private static final Cookie TOKEN_COOKIE = new Cookie("token", "token"); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + AdminAuthCommandService adminAuthCommandService; + + @Nested + class 어드민_로그인 { + + final String uri = "/admin/api/v1/auth/login"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_로그인_토큰이_담긴_쿠키가_반환된다() throws Exception { + // given + var request = new AdminLoginV1Request("admin", "1234"); + given(adminAuthCommandService.login(any(AdminLoginCommand.class))) + .willReturn(new AdminLoginResult("admin", AuthType.ROOT, "token")); + + // when & then + mockMvc.perform(post(uri) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(cookie().exists(TOKEN_COOKIE.getName())) + .andExpect(cookie().path(TOKEN_COOKIE.getName(), "/")) + .andExpect(cookie().secure(TOKEN_COOKIE.getName(), true)) + .andExpect(cookie().httpOnly(TOKEN_COOKIE.getName(), true)) + .andExpect(cookie().sameSite(TOKEN_COOKIE.getName(), "None")); + } + } + } + + @Nested + class 어드민_로그아웃 { + + final String uri = "/admin/api/v1/auth/logout"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답과_비어있는_값의_로그인_토큰이_담긴_쿠키가_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isOk()) + .andExpect(cookie().exists(TOKEN_COOKIE.getName())) + .andExpect(cookie().value(TOKEN_COOKIE.getName(), "")) + .andExpect(cookie().path(TOKEN_COOKIE.getName(), "/")) + .andExpect(cookie().secure(TOKEN_COOKIE.getName(), true)) + .andExpect(cookie().httpOnly(TOKEN_COOKIE.getName(), true)) + .andExpect(cookie().sameSite(TOKEN_COOKIE.getName(), "None")); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 어드민_회원가입 { + + final String uri = "/admin/api/v1/auth/signup"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth(role = Role.ADMIN) + void 요청을_보내면_200_응답과_생성한_계정이_반환된다() throws Exception { + var request = new AdminSignupV1Request("newAdmin", "1234"); + + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth(role = Role.MEMBER) + void 토큰의_권한이_Admin이_아니면_404_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri) + .cookie(TOKEN_COOKIE)) + .andExpect(status().isNotFound()); + } + } + } + + @Nested + class 루트_어드민_활성화 { + + final String uri = "/admin/api/v1/auth/initialize"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + var request = new RootAdminInitializeRequest("1234"); + + // when & then + mockMvc.perform(post(uri) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + @WithMockAuth(role = Role.ANONYMOUS) + void 권한이_없어도_200_응답이_반환된다() throws Exception { + // given + var request = new RootAdminInitializeRequest("1234"); + + // when & then + mockMvc.perform(post(uri) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/auth/presentation/v1/MemberAuthV1ControllerTest.java b/backend/src/test/java/com/festago/auth/presentation/v1/MemberAuthV1ControllerTest.java new file mode 100644 index 000000000..30a5d384c --- /dev/null +++ b/backend/src/test/java/com/festago/auth/presentation/v1/MemberAuthV1ControllerTest.java @@ -0,0 +1,191 @@ +package com.festago.auth.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.auth.domain.SocialType; +import com.festago.auth.dto.v1.OAuth2LoginV1Request; +import com.festago.auth.dto.v1.OpenIdLoginV1Request; +import com.festago.auth.dto.v1.RefreshTokenV1Request; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberAuthV1ControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Nested + class 회원_로그인 { + + final String uri = "/api/v1/auth/login/oauth2"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_로그인_응답이_반환된다() throws Exception { + // given + var request = new OAuth2LoginV1Request(SocialType.FESTAGO, "token"); + + // when & then + mockMvc.perform(post(uri) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + } + } + + @Nested + class 회원_로그인_withPath { + + final String uri = "/api/v1/auth/login/oauth2/{socialType}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_로그인_응답이_반환된다() throws Exception { + // given + String code = "1"; + + // when & then + mockMvc.perform(get(uri, "festago") + .queryParam("code", code) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + } + } + + @Nested + class OpenId_회원_로그인 { + + final String uri = "/api/v1/auth/login/open-id"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_로그인_응답이_반환된다() throws Exception { + // given + var request = new OpenIdLoginV1Request(SocialType.FESTAGO, "token"); + + // when & then + mockMvc.perform(post(uri) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + } + } + + @Nested + class 회원_로그아웃 { + + final String uri = "/api/v1/auth/logout"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + var request = new RefreshTokenV1Request(UUID.randomUUID().toString()); + + // when & then + mockMvc.perform(post(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer 12312") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(post(uri)) + .andExpect(status().isUnauthorized()); + } + } + } + + @Nested + class 리프레쉬 { + + final String uri = "/api/v1/auth/refresh"; + + @Nested + @DisplayName("POST " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + var request = new RefreshTokenV1Request(UUID.randomUUID().toString()); + + // when & then + mockMvc.perform(post(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer 12312") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + } + } + + @Nested + class 회원_탈퇴 { + + final String uri = "/api/v1/auth"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // given + + // when & then + mockMvc.perform(delete(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer 12312") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri)) + .andExpect(status().isUnauthorized()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/auth/repository/MemoryRefreshTokenRepository.java b/backend/src/test/java/com/festago/auth/repository/MemoryRefreshTokenRepository.java new file mode 100644 index 000000000..b00716458 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/repository/MemoryRefreshTokenRepository.java @@ -0,0 +1,27 @@ +package com.festago.auth.repository; + +import com.festago.auth.domain.RefreshToken; +import java.util.HashMap; +import java.util.Optional; +import java.util.UUID; + +public class MemoryRefreshTokenRepository implements RefreshTokenRepository { + + private final HashMap memory = new HashMap<>(); + + @Override + public RefreshToken save(RefreshToken refreshToken) { + memory.put(refreshToken.getId(), refreshToken); + return refreshToken; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(memory.get(id)); + } + + @Override + public void deleteById(UUID id) { + memory.remove(id); + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/command/ArtistBookmarkCommandServiceTest.java b/backend/src/test/java/com/festago/bookmark/application/command/ArtistBookmarkCommandServiceTest.java new file mode 100644 index 000000000..e44bca38b --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/command/ArtistBookmarkCommandServiceTest.java @@ -0,0 +1,123 @@ +package com.festago.bookmark.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.artist.repository.MemoryArtistRepository; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.bookmark.repository.MemoryBookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.BookmarkFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistBookmarkCommandServiceTest { + + ArtistRepository artistRepository; + BookmarkRepository bookmarkRepository; + ArtistBookmarkCommandService artistBookmarkCommandService; + + Long 회원_식별자 = 1234L; + Artist 브라운; + + @BeforeEach + void setting() { + initializeRepository(); + 브라운 = artistRepository.save(ArtistFixture.builder() + .name("브라운") + .build()); + } + + private void initializeRepository() { + artistRepository = new MemoryArtistRepository(); + bookmarkRepository = new MemoryBookmarkRepository(); + artistBookmarkCommandService = new ArtistBookmarkCommandService(bookmarkRepository, artistRepository); + } + + @Nested + class 북마크_저장 { + + @Test + void 존재_하지_않는_아티스트로_저장하면_예외가_발생한다() { + // given & when & then + assertThatThrownBy(() -> artistBookmarkCommandService.save(1000L, 회원_식별자)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.ARTIST_NOT_FOUND.getMessage()); + } + + @Test + void 최대_북마크_개수를_넘기면_에외가_발생한다() { + // given + for (int i = 0; i < 12; i++) { + Artist artist = artistRepository.save(ArtistFixture.builder().build()); + bookmarkRepository.save( + BookmarkFixture.builder() + .bookmarkType(BookmarkType.ARTIST) + .resourceId(artist.getId()) + .memberId(회원_식별자) + .build() + ); + } + + // when & then + assertThatThrownBy(() -> artistBookmarkCommandService.save(브라운.getId(), 회원_식별자)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.BOOKMARK_LIMIT_EXCEEDED.getMessage()); + } + + @Test + void 북마크를_저장한다() { + // when + artistBookmarkCommandService.save(브라운.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.ARTIST)) + .isNotZero(); + } + + @Test + void 기존에_저장된_북마크가_있으면_저장되지_않는다() { + // given + artistBookmarkCommandService.save(브라운.getId(), 회원_식별자); + + // when + artistBookmarkCommandService.save(브라운.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.ARTIST)) + .isOne(); + } + } + + @Nested + class 북마크_삭제 { + + @Test + void 북마크를_삭제한다() { + // given + bookmarkRepository.save(BookmarkFixture.builder() + .bookmarkType(BookmarkType.ARTIST) + .resourceId(브라운.getId()) + .memberId(회원_식별자) + .build()); + + // when + artistBookmarkCommandService.delete(브라운.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.ARTIST)) + .isZero(); + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/command/FestivalBookmarkCommandServiceTest.java b/backend/src/test/java/com/festago/bookmark/application/command/FestivalBookmarkCommandServiceTest.java new file mode 100644 index 000000000..d07c870b8 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/command/FestivalBookmarkCommandServiceTest.java @@ -0,0 +1,123 @@ +package com.festago.bookmark.application.command; + +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 com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.bookmark.repository.MemoryBookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.festival.repository.MemoryFestivalRepository; +import com.festago.support.fixture.BookmarkFixture; +import com.festago.support.fixture.FestivalFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalBookmarkCommandServiceTest { + + BookmarkRepository bookmarkRepository; + + FestivalRepository festivalRepository; + + FestivalBookmarkCommandService festivalBookmarkCommandService; + + Long 회원_식별자 = 1234L; + Festival 축제; + + @BeforeEach + void setUp() { + bookmarkRepository = new MemoryBookmarkRepository(); + festivalRepository = new MemoryFestivalRepository(); + festivalBookmarkCommandService = new FestivalBookmarkCommandService(bookmarkRepository, festivalRepository); + 축제 = festivalRepository.save(FestivalFixture.builder().build()); + } + + @Nested + class 북마크_저장 { + + @Test + void 북마크로_저장하려는_축제가_존재하지_않으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> festivalBookmarkCommandService.save(4885L, 회원_식별자)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.FESTIVAL_NOT_FOUND.getMessage()); + } + + @Test + void 기존에_저장된_북마크의_개수가_12개_이상이면_예외가_발생한다() { + // given + for (long i = 1; i <= 12; i++) { + Festival festival = festivalRepository.save(FestivalFixture.builder().build()); + bookmarkRepository.save( + BookmarkFixture.builder() + .bookmarkType(BookmarkType.FESTIVAL) + .resourceId(festival.getId()) + .memberId(회원_식별자) + .build() + ); + } + + // when & then + assertThatThrownBy(() -> festivalBookmarkCommandService.save(축제.getId(), 회원_식별자)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.BOOKMARK_LIMIT_EXCEEDED.getMessage()); + } + + @Test + void 북마크를_저장한다() { + // when + festivalBookmarkCommandService.save(축제.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.FESTIVAL)) + .isNotZero(); + } + + @Test + void 기존에_저장된_북마크가_있으면_저장되지_않는다() { + // given + festivalBookmarkCommandService.save(축제.getId(), 회원_식별자); + + // when + festivalBookmarkCommandService.save(축제.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.FESTIVAL)) + .isOne(); + } + } + + @Nested + class 북마크_삭제 { + + @Test + void 존재하지_않는_북마크를_삭제하더라도_예외가_발생하지_않는다() { + // when & then + assertThatNoException() + .isThrownBy(() -> festivalBookmarkCommandService.delete(4885L, 회원_식별자)); + } + + @Test + void 북마크를_삭제할_수_있다() { + // given + festivalBookmarkCommandService.save(축제.getId(), 회원_식별자); + + // when + festivalBookmarkCommandService.delete(축제.getId(), 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.FESTIVAL)) + .isZero(); + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/command/SchoolBookmarkCommandServiceTest.java b/backend/src/test/java/com/festago/bookmark/application/command/SchoolBookmarkCommandServiceTest.java new file mode 100644 index 000000000..504112e5e --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/command/SchoolBookmarkCommandServiceTest.java @@ -0,0 +1,126 @@ +package com.festago.bookmark.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.bookmark.repository.MemoryBookmarkRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.school.domain.School; +import com.festago.school.repository.MemorySchoolRepository; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.fixture.BookmarkFixture; +import com.festago.support.fixture.SchoolFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolBookmarkCommandServiceTest { + + BookmarkRepository bookmarkRepository; + SchoolRepository schoolRepository; + SchoolBookmarkCommandService schoolBookmarkCommandService; + + Long 회원_식별자 = 1234L; + + @BeforeEach + void setUp() { + bookmarkRepository = new MemoryBookmarkRepository(); + schoolRepository = new MemorySchoolRepository(); + schoolBookmarkCommandService = new SchoolBookmarkCommandService(bookmarkRepository, schoolRepository); + } + + @Nested + class 학교_북마크_추가시 { + + @Test + void 해당하는_학교가_없으면_예외() { + // when && then + assertThatThrownBy(() -> schoolBookmarkCommandService.save(-1L, 회원_식별자)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.SCHOOL_NOT_FOUND.getMessage()); + } + + @Test + void 학교_북마크_갯수가_이미_12개_이상이면_예외() { + // given + for (long i = 0; i < 12; i++) { + School school = schoolRepository.save(SchoolFixture.builder().build()); + bookmarkRepository.save( + BookmarkFixture.builder() + .bookmarkType(BookmarkType.SCHOOL) + .resourceId(school.getId()) + .memberId(회원_식별자) + .build() + ); + } + + Long schoolId = schoolRepository.save(SchoolFixture.builder().build()).getId(); + + // when && then + assertThatThrownBy(() -> schoolBookmarkCommandService.save(schoolId, 회원_식별자)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.BOOKMARK_LIMIT_EXCEEDED.getMessage()); + } + + @Test + void 이미_해당하는_북마크가_저장됐다면_예외() { + // given + Long schoolId = schoolRepository.save(SchoolFixture.builder().build()).getId(); + bookmarkRepository.save( + BookmarkFixture.builder() + .bookmarkType(BookmarkType.SCHOOL) + .resourceId(schoolId) + .memberId(회원_식별자) + .build() + ); + + // when + schoolBookmarkCommandService.save(schoolId, 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.SCHOOL)) + .isOne(); + } + + @Test + void 북마크_저장_성공() { + // given + Long schoolId = schoolRepository.save(SchoolFixture.builder().build()).getId(); + + // when + schoolBookmarkCommandService.save(schoolId, 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.SCHOOL)) + .isNotZero(); + } + } + + @Test + void 북마크를_삭제한다() { + // given + Long schoolId = schoolRepository.save(SchoolFixture.builder().build()).getId(); + bookmarkRepository.save( + BookmarkFixture.builder() + .bookmarkType(BookmarkType.SCHOOL) + .resourceId(schoolId) + .memberId(회원_식별자) + .build() + ); + + // when + schoolBookmarkCommandService.delete(schoolId, 회원_식별자); + + // then + assertThat(bookmarkRepository.countByMemberIdAndBookmarkType(회원_식별자, BookmarkType.SCHOOL)) + .isZero(); + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/integration/ArtistBookmarkV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/bookmark/application/integration/ArtistBookmarkV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..891cba863 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/integration/ArtistBookmarkV1QueryServiceIntegrationTest.java @@ -0,0 +1,128 @@ +package com.festago.bookmark.application.integration; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.bookmark.application.ArtistBookmarkV1QueryService; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.ArtistBookmarkV1Response; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.member.domain.Member; +import com.festago.member.repository.MemberRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.BookmarkFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistBookmarkV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + ArtistRepository artistRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + MemberRepository memberRepository; + + @Autowired + BookmarkRepository bookmarkRepository; + + @Autowired + ArtistBookmarkV1QueryService artistBookmarkV1QueryService; + + Artist 네오; + Artist 브라운; + Artist 브리; + + Member 푸우; + Member 오리; + Member 글렌; + + @BeforeEach + void setting() { + 네오 = artistRepository.save(ArtistFixture.builder() + .name("네오") + .build()); + 브라운 = artistRepository.save(ArtistFixture.builder() + .name("브라운") + .build()); + 브리 = artistRepository.save(ArtistFixture.builder() + .name("브리") + .build()); + + 푸우 = memberRepository.save(MemberFixture.builder() + .nickname("푸우") + .build()); + 오리 = memberRepository.save(MemberFixture.builder() + .nickname("오리") + .build()); + 글렌 = memberRepository.save(MemberFixture.builder() + .nickname("글렌") + .build()); + } + + @Test + void 유저의_아티스트_북마크_목록을_반환한다() { + // given + createBookmark(브리.getId(), 푸우.getId()); + createBookmark(네오.getId(), 푸우.getId()); + createBookmark(브라운.getId(), 오리.getId()); + + // when + List actual = artistBookmarkV1QueryService.findArtistBookmarksByMemberId( + 푸우.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual).extracting("artist").extracting("name").contains("브리", "네오"); + }); + } + + @Test + void 북마크들_중_아티스트에_대한_북마크만_가져온다() { + // given + createBookmark(브리.getId(), 푸우.getId()); + createBookmark(네오.getId(), 푸우.getId()); + School school = schoolRepository.save(SchoolFixture.builder().build()); + bookmarkRepository.save( + BookmarkFixture.builder() + .bookmarkType(BookmarkType.SCHOOL) + .resourceId(school.getId()) + .memberId(푸우.getId()) + .build() + ); + + // when + List actual = artistBookmarkV1QueryService.findArtistBookmarksByMemberId( + 푸우.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual).extracting("artist").extracting("name").contains("브리", "네오"); + }); + } + + public void createBookmark(Long resourceId, Long memberId) { + bookmarkRepository.save(BookmarkFixture.builder() + .bookmarkType(BookmarkType.ARTIST) + .resourceId(resourceId) + .memberId(memberId) + .build() + ); + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/integration/FestivalBookmarkV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/bookmark/application/integration/FestivalBookmarkV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..f22788d0d --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/integration/FestivalBookmarkV1QueryServiceIntegrationTest.java @@ -0,0 +1,190 @@ +package com.festago.bookmark.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.bookmark.application.FestivalBookmarkV1QueryService; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.bookmark.repository.FestivalBookmarkOrder; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.member.repository.MemberRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.BookmarkFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.FestivalQueryInfoFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalBookmarkV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + BookmarkRepository bookmarkRepository; + + @Autowired + FestivalBookmarkV1QueryService festivalBookmarkV1QueryService; + + @Autowired + FestivalInfoRepository festivalQueryInfoRepository; + + @Autowired + MemberRepository memberRepository; + + Long 회원A_식별자; + Long 회원B_식별자; + Long 회원C_식별자; + + School 테코대학교; + School 우테대학교; + + Long 테코대학교_봄_축제_식별자; + Long 우테대학교_여름_축제_식별자; + Long 우테대학교_가을_축제_식별자; + + @BeforeEach + void setUp() { + 회원A_식별자 = memberRepository.save(MemberFixture.builder().socialId("1").nickname("회원A").build()).getId(); + 회원B_식별자 = memberRepository.save(MemberFixture.builder().socialId("2").nickname("회원B").build()).getId(); + 회원C_식별자 = memberRepository.save(MemberFixture.builder().socialId("3").nickname("회원C").build()).getId(); + + 테코대학교 = createSchool("테코대학교"); + 우테대학교 = createSchool("우테대학교"); + + 테코대학교_봄_축제_식별자 = createFestival("테코대학교 봄 축제", 테코대학교, LocalDate.parse("2077-03-01")); + 우테대학교_여름_축제_식별자 = createFestival("우테대학교 여름 축제", 우테대학교, LocalDate.parse("2077-07-01")); + 우테대학교_가을_축제_식별자 = createFestival("우테대학교 가을 축제", 우테대학교, LocalDate.parse("2077-10-01")); + } + + private School createSchool(String schoolName) { + return schoolRepository.save(SchoolFixture.builder().name(schoolName).build()); + } + + private Long createFestival(String festivalName, School school, LocalDate startDate) { + Long festivalId = festivalRepository.save(FestivalFixture.builder() + .name(festivalName) + .startDate(startDate) + .endDate(startDate.plusDays(2)) + .school(school) + .build()).getId(); + festivalQueryInfoRepository.save( + FestivalQueryInfoFixture.builder() + .festivalId(festivalId) + .artistInfo("").build() + ); + return festivalId; + } + + @Nested + class findBookmarkedFestivalIds { + + @Test + void 회원의_식별자로_북마크한_축제의_식별자를_조회한다() { + // given + createBookmark(테코대학교_봄_축제_식별자, 회원A_식별자); + createBookmark(우테대학교_여름_축제_식별자, 회원B_식별자); + createBookmark(우테대학교_가을_축제_식별자, 회원B_식별자); + + // when + var 회원A_축제_북마크_식별자_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivalIds(회원A_식별자); + var 회원B_축제_북마크_식별자_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivalIds(회원B_식별자); + var 회원C_축제_북마크_식별자_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivalIds(회원C_식별자); + + // then + assertSoftly(softly -> { + softly.assertThat(회원A_축제_북마크_식별자_목록).containsExactlyInAnyOrder(테코대학교_봄_축제_식별자); + softly.assertThat(회원B_축제_북마크_식별자_목록).containsExactlyInAnyOrder(우테대학교_여름_축제_식별자, 우테대학교_가을_축제_식별자); + softly.assertThat(회원C_축제_북마크_식별자_목록).isEmpty(); + }); + } + } + + @Nested + class findBookmarkedFestivals { + + @Test + void 북마크를_등록한_시간의_내림차순으로_조회할_수_있다() { + // given + createBookmark(우테대학교_여름_축제_식별자, 회원A_식별자); + createBookmark(테코대학교_봄_축제_식별자, 회원A_식별자); + createBookmark(우테대학교_가을_축제_식별자, 회원A_식별자); + + // when + var 회원A_북마크_축제_정보_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivals( + 회원A_식별자, + List.of(테코대학교_봄_축제_식별자, 우테대학교_여름_축제_식별자, 우테대학교_가을_축제_식별자), + FestivalBookmarkOrder.BOOKMARK + ); + + // then + assertThat(회원A_북마크_축제_정보_목록) + .map(FestivalBookmarkV1Response::festival) + .map(FestivalV1Response::id) + .containsExactly(우테대학교_가을_축제_식별자, 테코대학교_봄_축제_식별자, 우테대학교_여름_축제_식별자); + } + + @Test + void 축제의_시작_시간의_오름차순으로_조회할_수_있다() { + // given + createBookmark(우테대학교_여름_축제_식별자, 회원A_식별자); + createBookmark(테코대학교_봄_축제_식별자, 회원A_식별자); + createBookmark(우테대학교_가을_축제_식별자, 회원A_식별자); + + // when + var 회원A_북마크_축제_정보_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivals( + 회원A_식별자, + List.of(우테대학교_가을_축제_식별자, 우테대학교_여름_축제_식별자, 테코대학교_봄_축제_식별자), + FestivalBookmarkOrder.FESTIVAL + ); + + // then + assertThat(회원A_북마크_축제_정보_목록) + .map(FestivalBookmarkV1Response::festival) + .map(FestivalV1Response::id) + .containsExactly(테코대학교_봄_축제_식별자, 우테대학교_여름_축제_식별자, 우테대학교_가을_축제_식별자); + } + + @Test + void 북마크에_등록되지_않은_축제_식별자를_보내면_해당_축제는_조회되지_않는다() { + // given + createBookmark(테코대학교_봄_축제_식별자, 회원A_식별자); + + // when + var 회원A_북마크_축제_정보_목록 = festivalBookmarkV1QueryService.findBookmarkedFestivals( + 회원A_식별자, + List.of(우테대학교_여름_축제_식별자, 우테대학교_가을_축제_식별자), + FestivalBookmarkOrder.BOOKMARK + ); + + assertThat(회원A_북마크_축제_정보_목록).isEmpty(); + } + } + + public void createBookmark(Long resourceId, Long memberId) { + bookmarkRepository.save(BookmarkFixture.builder() + .bookmarkType(BookmarkType.FESTIVAL) + .resourceId(resourceId) + .memberId(memberId) + .build()); + } +} diff --git a/backend/src/test/java/com/festago/bookmark/application/integration/SchoolBookmarkV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/bookmark/application/integration/SchoolBookmarkV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..1981e95c6 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/application/integration/SchoolBookmarkV1QueryServiceIntegrationTest.java @@ -0,0 +1,91 @@ +package com.festago.bookmark.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.bookmark.application.SchoolBookmarkV1QueryService; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.bookmark.dto.v1.SchoolBookmarkInfoV1Response; +import com.festago.bookmark.dto.v1.SchoolBookmarkV1Response; +import com.festago.bookmark.repository.BookmarkRepository; +import com.festago.member.repository.MemberRepository; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.BookmarkFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolBookmarkV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + SchoolBookmarkV1QueryService schoolBookmarkV1QueryService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + MemberRepository memberRepository; + + @Autowired + BookmarkRepository bookmarkRepository; + + @Test + void 특정_회원의_북마크_목록들을_검색한다() { + // given + var 회원A_ID = saveMember("socialId_A"); + var 회원B_ID = saveMember("socialId_B"); + + var 학교A_ID = saveSchool("A대학교", "a.ac.kr", "https://www.festago.com/A.png"); + var 학교B_ID = saveSchool("B대학교", "b.ac.kr", "https://www.festago.com/B.png"); + + saveBookmark(학교A_ID, 회원A_ID); + saveBookmark(학교B_ID, 회원A_ID); + + saveBookmark(학교A_ID, 회원B_ID); + saveBookmark(학교B_ID, 회원B_ID); + + // when + var actual = schoolBookmarkV1QueryService.findAllByMemberId(회원A_ID); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual).allSatisfy(it -> assertThat(it).hasNoNullFieldsOrProperties()); + softly.assertThat(actual).map(SchoolBookmarkV1Response::school) + .containsExactly( + new SchoolBookmarkInfoV1Response(학교A_ID, "A대학교", "https://www.festago.com/A.png"), + new SchoolBookmarkInfoV1Response(학교B_ID, "B대학교", "https://www.festago.com/B.png") + ); + }); + } + + private Long saveMember(String socialId) { + return memberRepository.save(MemberFixture.builder() + .socialId(socialId) + .build()).getId(); + } + + private Long saveSchool(String name, String domain, String logoUrl) { + return schoolRepository.save(SchoolFixture.builder() + .name(name) + .domain(domain) + .logoUrl(logoUrl) + .build()).getId(); + } + + private void saveBookmark(Long schoolId, Long memberId) { + bookmarkRepository.save( + BookmarkFixture.builder() + .bookmarkType(BookmarkType.SCHOOL) + .resourceId(schoolId) + .memberId(memberId) + .build() + ); + } +} diff --git a/backend/src/test/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1ControllerTest.java b/backend/src/test/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1ControllerTest.java new file mode 100644 index 000000000..f9272a32a --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/presentation/v1/ArtistBookmarkV1ControllerTest.java @@ -0,0 +1,59 @@ +package com.festago.bookmark.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ArtistBookmarkV1ControllerTest { + + private static final String TOKEN = "Bearer token"; + + @Autowired + MockMvc mockMvc; + + @Nested + class 아티스트_북마크_목록_조회_에서 { + + final String uri = "/api/v1/bookmarks/artists"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답을_반환한다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .header(HttpHeaders.AUTHORIZATION, TOKEN) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void 인증을_안했으면_4xx_응답을_반환한다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1ControllerTest.java b/backend/src/test/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1ControllerTest.java new file mode 100644 index 000000000..39deb75cf --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/presentation/v1/BookmarkManagementV1ControllerTest.java @@ -0,0 +1,89 @@ +package com.festago.bookmark.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class BookmarkManagementV1ControllerTest { + + private static final String TOKEN = "Bearer token"; + + @Autowired + MockMvc mockMvc; + + @Nested + class 북마크_등록 { + + final String uri = "/api/v1/bookmarks"; + + String resourceId = "1"; + + @Nested + @DisplayName("PUT " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(put(uri) + .param("resourceId", resourceId) + .param("bookmarkType", "FESTIVAL") + .header(HttpHeaders.AUTHORIZATION, TOKEN)) + .andExpect(status().isOk()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(put(uri)) + .andExpect(status().isUnauthorized()); + } + } + } + + @Nested + class 북마크_삭제 { + + final String uri = "/api/v1/bookmarks"; + + String resourceId = "1"; + + @Nested + @DisplayName("DELETE " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_204_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri) + .param("resourceId", resourceId) + .param("bookmarkType", "FESTIVAL") + .header(HttpHeaders.AUTHORIZATION, TOKEN)) + .andExpect(status().isNoContent()); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(delete(uri)) + .andExpect(status().isUnauthorized()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1ControllerTest.java b/backend/src/test/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1ControllerTest.java new file mode 100644 index 000000000..8dee24020 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/presentation/v1/FestivalBookmarkV1ControllerTest.java @@ -0,0 +1,135 @@ +package com.festago.bookmark.presentation.v1; + +import static org.hamcrest.Matchers.contains; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.anyList; +import static org.mockito.BDDMockito.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.bookmark.application.FestivalBookmarkV1QueryService; +import com.festago.bookmark.dto.v1.FestivalBookmarkV1Response; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.dto.SchoolV1Response; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalBookmarkV1ControllerTest { + + private static final String TOKEN = "Bearer token"; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + MockMvc mockMvc; + + @Autowired + FestivalBookmarkV1QueryService festivalBookmarkV1QueryService; + + @Nested + class 축제_북마크_축제_식별자_목록_조회 { + + final String uri = "/api/v1/bookmarks/festivals/ids"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답과_북마크한_축제_식별자_목록이_반환된다() throws Exception { + // given + given(festivalBookmarkV1QueryService.findBookmarkedFestivalIds(anyLong())) + .willReturn(List.of(1L, 2L, 3L)); + + // when & then + mockMvc.perform(get(uri) + .header(HttpHeaders.AUTHORIZATION, TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").value(contains(1, 2, 3))); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + } + } + + @Nested + class 축제_북마크_축제_목록_조회 { + + final String uri = "/api/v1/bookmarks/festivals"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 식별자_목록과_정렬_기준_요청을_보내면_200_응답과_축제_목록이_반환된다() throws Exception { + // given + given(festivalBookmarkV1QueryService.findBookmarkedFestivals(anyLong(), anyList(), any())) + .willReturn(List.of( + createFestivalV1Response(1L, "테코대학교 봄 축제"), + createFestivalV1Response(2L, "테코대학교 여름 축제"), + createFestivalV1Response(3L, "테코대학교 가을 축제") + )); + + // when & then + mockMvc.perform(get(uri) + .header(HttpHeaders.AUTHORIZATION, TOKEN) + .queryParam("festivalIds", "1,2,3") + .queryParam("festivalBookmarkOrder", "FESTIVAL")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[*].festival.id").value(contains(1, 2, 3))); + } + + private FestivalBookmarkV1Response createFestivalV1Response(Long festivalId, String festivalName) { + LocalDate startDate = LocalDate.now(); + LocalDate endDate = LocalDate.now(); + return new FestivalBookmarkV1Response( + new FestivalV1Response( + festivalId, + festivalName, + startDate, + endDate, + "https://image.com/posterImage.png", + new SchoolV1Response(1L, "테코대학교"), + "[]" + ), + LocalDateTime.now() + ); + } + + @Test + void 토큰_없이_보내면_401_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri)) + .andExpect(status().isUnauthorized()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1ControllerTest.java b/backend/src/test/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1ControllerTest.java new file mode 100644 index 000000000..59963cf4f --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/presentation/v1/SchoolBookmarkV1ControllerTest.java @@ -0,0 +1,59 @@ +package com.festago.bookmark.presentation.v1; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolBookmarkV1ControllerTest { + + private static final String TOKEN = "Bearer token"; + + @Autowired + MockMvc mockMvc; + + @Nested + class 학교_북마크_목록_조회 { + + final String uri = "/api/v1/bookmarks/schools"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + @WithMockAuth + void 요청을_보내면_200_응답을_반환한다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .header(HttpHeaders.AUTHORIZATION, TOKEN) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void 인증을_안했으면_4xx_응답을_반환한다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/bookmark/repository/MemoryBookmarkRepository.java b/backend/src/test/java/com/festago/bookmark/repository/MemoryBookmarkRepository.java new file mode 100644 index 000000000..d4922eca2 --- /dev/null +++ b/backend/src/test/java/com/festago/bookmark/repository/MemoryBookmarkRepository.java @@ -0,0 +1,50 @@ +package com.festago.bookmark.repository; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; +import com.festago.support.AbstractMemoryRepository; +import java.util.Objects; +import java.util.Optional; + +public class MemoryBookmarkRepository extends AbstractMemoryRepository implements BookmarkRepository { + + @Override + public boolean existsByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType bookmarkType, + Long memberId, + Long resourceId + ) { + return getByBookmarkTypeAndMemberIdAndResourceId(bookmarkType, memberId, resourceId) + .isPresent(); + } + + private Optional getByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType bookmarkType, + Long memberId, + Long resourceId + ) { + return memory.values().stream() + .filter(bookmark -> bookmark.getBookmarkType() == bookmarkType) + .filter(bookmark -> Objects.equals(bookmark.getMemberId(), memberId)) + .filter(bookmark -> Objects.equals(bookmark.getResourceId(), resourceId)) + .findAny(); + } + + @Override + public long countByMemberIdAndBookmarkType(Long memberId, BookmarkType bookmarkType) { + return memory.values().stream() + .filter(bookmark -> Objects.equals(bookmark.getMemberId(), memberId)) + .filter(bookmark -> bookmark.getBookmarkType() == bookmarkType) + .count(); + } + + @Override + public void deleteByBookmarkTypeAndMemberIdAndResourceId( + BookmarkType bookmarkType, + Long memberId, + Long resourceId + ) { + getByBookmarkTypeAndMemberIdAndResourceId(bookmarkType, memberId, resourceId) + .ifPresent(it -> memory.remove(it.getId())); + } +} diff --git a/backend/src/test/java/com/festago/common/aop/ValidPageableAspectTest.java b/backend/src/test/java/com/festago/common/aop/ValidPageableAspectTest.java new file mode 100644 index 000000000..48cb26c3d --- /dev/null +++ b/backend/src/test/java/com/festago/common/aop/ValidPageableAspectTest.java @@ -0,0 +1,84 @@ +package com.festago.common.aop; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.common.exception.ErrorCode; +import com.festago.support.CustomWebMvcTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@CustomWebMvcTest +class ValidPageableAspectTest { + + @Autowired + MockMvc mockMvc; + + @Test + void size가_없으면_200_응답이_반환된다() throws Exception { + mockMvc.perform(get("/test") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void size가_maxSize를_초과하면_400_응답이_반환된다() throws Exception { + mockMvc.perform(get("/test") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("size", "11")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(ErrorCode.INVALID_PAGING_MAX_SIZE.getMessage())); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "-1"}) + void size가_0_또는_음수이면_400_응답이_반환된다(String size) throws Exception { + mockMvc.perform(get("/test") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("size", size)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(ErrorCode.INVALID_NUMBER_FORMAT_PAGING_SIZE.getMessage())); + } + + @Test + void size가_정수로_파싱할_수_없으면_400_응답이_반환된다() throws Exception { + mockMvc.perform(get("/test") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("size", "size")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(ErrorCode.INVALID_NUMBER_FORMAT_PAGING_SIZE.getMessage())); + } + + @Test + void sizeKey를_지정할_수_있다() throws Exception { + mockMvc.perform(get("/test-custom-size-key") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("limit", "11")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(ErrorCode.INVALID_PAGING_MAX_SIZE.getMessage())); + } +} + +@RestController +class TestController { + + @GetMapping("/test") + @ValidPageable(maxSize = 10) + public ResponseEntity testHandler() { + return ResponseEntity.ok().build(); + } + + @GetMapping("/test-custom-size-key") + @ValidPageable(maxSize = 10, sizeKey = "limit") + public ResponseEntity testHandler_with_customSizeKey() { + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/test/java/com/festago/exception/FestaGoExceptionTest.java b/backend/src/test/java/com/festago/common/exception/FestaGoExceptionTest.java similarity index 92% rename from backend/src/test/java/com/festago/exception/FestaGoExceptionTest.java rename to backend/src/test/java/com/festago/common/exception/FestaGoExceptionTest.java index b71a58dea..eb14ace99 100644 --- a/backend/src/test/java/com/festago/exception/FestaGoExceptionTest.java +++ b/backend/src/test/java/com/festago/common/exception/FestaGoExceptionTest.java @@ -1,10 +1,7 @@ -package com.festago.exception; +package com.festago.common.exception; import static org.assertj.core.api.Assertions.assertThat; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.FestaGoException; -import com.festago.common.exception.InternalServerException; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; diff --git a/backend/src/test/java/com/festago/common/filter/wrapping/UriPatternMatcherTest.java b/backend/src/test/java/com/festago/common/filter/wrapping/UriPatternMatcherTest.java new file mode 100644 index 000000000..290702de5 --- /dev/null +++ b/backend/src/test/java/com/festago/common/filter/wrapping/UriPatternMatcherTest.java @@ -0,0 +1,67 @@ +package com.festago.common.filter.wrapping; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RequestMethod; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class UriPatternMatcherTest { + + UriPatternMatcher uriPatternMatcher; + + @BeforeEach + void setUp() { + uriPatternMatcher = new UriPatternMatcher(); + } + + @Test + void 경로가_패턴에_매칭되면_참이다() { + // given + uriPatternMatcher.addPattern(Set.of(RequestMethod.POST), Set.of("/api/v1/schools")); + + // when & then + assertThat(uriPatternMatcher.match(RequestMethod.POST, "/api/v1/schools")).isTrue(); + } + + @Test + void 경로가_패턴에_매칭되지_않으면_거짓이다() { + // given + uriPatternMatcher.addPattern(Set.of(RequestMethod.POST), Set.of("/api/v1/schools")); + + // when & then + assertThat(uriPatternMatcher.match(RequestMethod.POST, "/api/v1/festivals")).isFalse(); + } + + @Test + void 경로가_매칭되어도_HttpMethod가_매칭되지_않으면_거짓이다() { + // given + uriPatternMatcher.addPattern(Set.of(RequestMethod.GET), Set.of("/api/v1/schools")); + + // when & then + assertThat(uriPatternMatcher.match(RequestMethod.POST, "/api/v1/schools")).isFalse(); + } + + @Test + void PathVariable_경로가_패턴에_매칭되어야_한다() { + // given + uriPatternMatcher.addPattern(Set.of(RequestMethod.GET), Set.of("/api/v1/schools/{schoolId}")); + + // when & then + assertThat(uriPatternMatcher.match(RequestMethod.GET, "/api/v1/schools/1")).isTrue(); + } + + @Test + void QueryParameter가_있어도_패턴에_매칭되어야_한다() { + // given + uriPatternMatcher.addPattern(Set.of(RequestMethod.GET), Set.of("/api/v1/schools/{schoolId}")); + + // when & then + assertThat(uriPatternMatcher.match(RequestMethod.GET, "/api/v1/schools/1?foo=bar")).isTrue(); + } +} diff --git a/backend/src/test/java/com/festago/common/presentation/PingControllerTest.java b/backend/src/test/java/com/festago/common/presentation/PingControllerTest.java new file mode 100644 index 000000000..b56630284 --- /dev/null +++ b/backend/src/test/java/com/festago/common/presentation/PingControllerTest.java @@ -0,0 +1,39 @@ +package com.festago.common.presentation; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.support.CustomWebMvcTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PingControllerTest { + + @Autowired + MockMvc mockMvc; + + @Nested + class 핑_요청 { + + final String uri = "/ping"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답이_반환된다() throws Exception { + mockMvc.perform(get(uri)) + .andExpect(status().isOk()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/common/util/ValidatorTest.java b/backend/src/test/java/com/festago/common/util/ValidatorTest.java index 8b8cf9abd..0f3a0513f 100644 --- a/backend/src/test/java/com/festago/common/util/ValidatorTest.java +++ b/backend/src/test/java/com/festago/common/util/ValidatorTest.java @@ -3,65 +3,312 @@ import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.festago.common.exception.UnexpectedException; +import com.festago.common.exception.ValidException; +import java.util.List; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") class ValidatorTest { - @Test - void 문자열_maxLength_검증_성공() { - // given - String input = "1234567890"; // 10 + @Nested + class notBlank { - // when & then - assertThatNoException() - .isThrownBy(() -> Validator.maxLength(input, 10, "")); + @ParameterizedTest + @NullSource + void 문자열이_null이면_예외(String input) { + // when & then + assertThatThrownBy(() -> Validator.notBlank(input, "")) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\t", "\n"}) + void 문자열이_공백이면_예외(String input) { + // when & then + assertThatThrownBy(() -> Validator.notBlank(input, "")) + .isInstanceOf(ValidException.class); + } + + @Test + void 문자열이_공백이_아니면_통과() { + // given + String input = "1"; + + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.notBlank(input, "")); + } + } + + + @Nested + class maxLength { + + @Test + void 문자열의_길이가_10이고_최대_길이가_10이면_통과() { + // given + String input = "1234567890"; // 10 + int maxLength = 10; + + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.maxLength(input, maxLength, "")); + } + + @Test + void 문자열의_길이가_11이고_최대_길이가_10이면_예외() { + // given + String input = "12345678901"; // 11 + int maxLength = 10; + + // when & then + assertThatThrownBy(() -> Validator.maxLength(input, maxLength, "")) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void 최대_길이가_0이하면_UnexpectedException(int maxLength) { + // given + String input = "1234567890"; // 10 + + // when & then + assertThatThrownBy(() -> Validator.maxLength(input, maxLength, "")) + .isInstanceOf(UnexpectedException.class); + } + + @ParameterizedTest + @NullSource + void 문자열이_null이면_통과(String input) { + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.maxLength(input, 10, "")); + } + } + + @Nested + class minLength { + + @Test + void 문자열의_길이가_10이고_최소_길이가_10이면_통과() { + // given + String input = "1234567890"; // 10 + int minLength = 10; + + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.minLength(input, minLength, "")); + } + + @Test + void 문자열의_길이가_9이고_최소_길이가_10이면_예외() { + // given + String input = "123456789"; // 9 + int minLength = 10; + + // when & then + assertThatThrownBy(() -> Validator.minLength(input, minLength, "")) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void 최소_길이가_0이하면_UnexpectedException(int minLength) { + // given + String input = "1234567890"; // 10 + + // when & then + assertThatThrownBy(() -> Validator.minLength(input, minLength, "")) + .isInstanceOf(UnexpectedException.class); + } + + @ParameterizedTest + @NullSource + void 문자열이_null이면_통과(String input) { + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.minLength(input, 10, "")); + } + } + + + @Nested + class notNull { + + @ParameterizedTest + @NullSource + void 객체가_null이면_예외(Object object) { + // when & then + assertThatThrownBy(() -> Validator.notNull(object, "")) + .isInstanceOf(ValidException.class); + } + + @Test + void 객체가_null이_아니면_통과() { + // given + Object object = ""; + + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.notNull(object, "")); + } } - @Test - void 문자열_maxLength_검증_실패() { - // given - String input = "12345678901"; // 11 + @Nested + class maxValue { + + @Test + void 값이_100이고_최대_값이_100이면_통과() { + // given + int value = 100; + int maxValue = 100; - // when & then - assertThatThrownBy(() -> Validator.maxLength(input, 10, "")) - .isInstanceOf(IllegalArgumentException.class); + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.maxValue(value, maxValue, "")); + } + + @Test + void 값이_101이고_최대_값이_100이면_예외() { + // given + int value = 101; + int maxValue = 100; + + // when & then + assertThatThrownBy(() -> Validator.maxValue(value, maxValue, "")) + .isInstanceOf(ValidException.class); + } } - @Test - void 문자열_maxLength_검증_null_성공() { - // when & then - assertThatNoException() - .isThrownBy(() -> Validator.maxLength(null, 10, "")); + @Nested + class minValue { + + @Test + void 값이_100이고_최소_값이_100이면_통과() { + // given + int value = 100; + int minValue = 100; + + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.minValue(value, minValue, "")); + } + + @Test + void 값이_99이고_최소_값이_100이면_예외() { + // given + int value = 99; + int minValue = 100; + + // when & then + assertThatThrownBy(() -> Validator.minValue(value, minValue, "")) + .isInstanceOf(ValidException.class); + } } - @Test - void 문자열_minLength_검증_성공() { - // given - String input = "1234567890"; // 10 + @Nested + class notNegative { - // when & then - assertThatNoException() - .isThrownBy(() -> Validator.minLength(input, 10, "")); + @Test + void 값이_음수이면_예외() { + // given + int value = -1; + + // when & then + assertThatThrownBy(() -> Validator.notNegative(value, "")) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1}) + void 값이_음수가_아니면_통과(int value) { + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.notNegative(value, "")); + } } - @Test - void 문자열_minLength_검증_실패() { - // given - String input = "123456789"; // 9 + @Nested + class maxSize { + + @Test + void 원소의_개수가_3이고_최대_개수가_3이면_통과() { + // given + int maxSize = 3; + List input = List.of(1, 2, 3); - // when & then - assertThatThrownBy(() -> Validator.minLength(input, 10, "")) - .isInstanceOf(IllegalArgumentException.class); + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.maxSize(input, maxSize, "input")); + } + + @Test + void 원소의_개수가_4이고_최대_개수가_3이면_예외() { + // given + int maxSize = 3; + List input = List.of(1, 2, 3, 4); + + // given + assertThatThrownBy(() -> Validator.maxSize(input, maxSize, "input")) + .isInstanceOf(ValidException.class); + } + } + + @Nested + class minSize { + + @Test + void 원소의_개수가_3이고_최소_개수가_3이면_통과() { + // given + int minSize = 3; + List input = List.of(1, 2, 3); + + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.minSize(input, minSize, "input")); + } + + @Test + void 원소의_개수가_2이고_최소_개수가_3이면_예외() { + // given + int minSize = 3; + List input = List.of(1, 2); + + // given + assertThatThrownBy(() -> Validator.minSize(input, minSize, "input")) + .isInstanceOf(ValidException.class); + } } - @Test - void 문자열_minLength_검증_null_성공() { - // when & then - assertThatNoException() - .isThrownBy(() -> Validator.minLength(null, 1, "")); + @Nested + class notDuplicate { + + @Test + void 리스트에_중복된_값이_없으면_통과() { + // given + List list = List.of(1, 2, 3); + + // when & then + assertThatNoException() + .isThrownBy(() -> Validator.notDuplicate(list, "list")); + } + + @Test + void 리스트에_중복된_값이_있으면_예외() { + // given + List list = List.of(1, 2, 1); + + // when & then + assertThatThrownBy(() -> Validator.notDuplicate(list, "list")) + .isInstanceOf(ValidException.class); + } } } diff --git a/backend/src/test/java/com/festago/domain/EntryCodeTest.java b/backend/src/test/java/com/festago/domain/EntryCodeTest.java deleted file mode 100644 index b85b744ad..000000000 --- a/backend/src/test/java/com/festago/domain/EntryCodeTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.festago.domain; - -import static com.festago.common.exception.ErrorCode.INVALID_ENTRY_CODE_OFFSET; -import static com.festago.common.exception.ErrorCode.INVALID_ENTRY_CODE_PERIOD; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; - -import com.festago.common.exception.InternalServerException; -import com.festago.entry.domain.EntryCode; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class EntryCodeTest { - - @ParameterizedTest - @ValueSource(longs = {0, -1}) - void 입장_코드의_수명이_0_또는_음수이면_예외(long period) { - // when & then - assertThatThrownBy(() -> new EntryCode("code", period, 0)) - .isInstanceOf(InternalServerException.class) - .hasMessage(INVALID_ENTRY_CODE_PERIOD.getMessage()); - } - - @Test - void 입장_코드의_오프셋이_음수이면_예외() { - // when & tehn - assertThatThrownBy(() -> new EntryCode("code", 30, -1)) - .isInstanceOf(InternalServerException.class) - .hasMessage(INVALID_ENTRY_CODE_OFFSET.getMessage()); - } - - @ParameterizedTest - @ValueSource(longs = {0, 1}) - void 입장_코드의_오프셋이_0이상이면_성공(long offset) { - assertThatNoException() - .isThrownBy(() -> new EntryCode("code", 30, offset)); - } -} diff --git a/backend/src/test/java/com/festago/domain/EntryStateTest.java b/backend/src/test/java/com/festago/domain/EntryStateTest.java deleted file mode 100644 index 1ea7bc851..000000000 --- a/backend/src/test/java/com/festago/domain/EntryStateTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.festago.domain; - -import static com.festago.common.exception.ErrorCode.INVALID_ENTRY_STATE_INDEX; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.festago.common.exception.InternalServerException; -import com.festago.ticketing.domain.EntryState; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class EntryStateTest { - - @ParameterizedTest - @ValueSource(ints = {-1, 3}) - void 유효하지않은_인덱스로_생성시_예외(int index) { - // when & then - assertThatThrownBy(() -> EntryState.from(index)) - .isInstanceOf(InternalServerException.class) - .hasMessage(INVALID_ENTRY_STATE_INDEX.getMessage()); - } - - @Test - void 인덱스로_생성시_인자가_null이면_예외() { - // when & then - assertThatThrownBy(() -> EntryState.from(null)) - .isInstanceOf(InternalServerException.class) - .hasMessage(INVALID_ENTRY_STATE_INDEX.getMessage()); - } - - @ParameterizedTest - @ValueSource(ints = {0, 1, 2}) - void 인덱스로_생성_성공(int index) { - // when & then - assertThatNoException().isThrownBy(() -> EntryState.from(index)); - } -} diff --git a/backend/src/test/java/com/festago/domain/FestivalTest.java b/backend/src/test/java/com/festago/domain/FestivalTest.java deleted file mode 100644 index ac29a3627..000000000 --- a/backend/src/test/java/com/festago/domain/FestivalTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.festago.domain; - -import static com.festago.common.exception.ErrorCode.INVALID_FESTIVAL_DURATION; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.festago.festival.domain.Festival; -import com.festago.school.domain.School; -import com.festago.support.FestivalFixture; -import com.festago.support.SchoolFixture; -import java.time.LocalDate; -import java.time.LocalDateTime; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class FestivalTest { - - @Test - void 시작일자가_종료일자_이전이면_예외() { - // given - LocalDate today = LocalDate.now(); - School school = SchoolFixture.school().build(); - LocalDate tomorrow = today.plusDays(1); - - // when & then - assertThatThrownBy(() -> new Festival("테코대학교", tomorrow, today, school)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage(INVALID_FESTIVAL_DURATION.getMessage()); - } - - @Nested - class 축제_기간_중이_아닌지_검사 { - - @Test - void 축제_시작_일자_이전이면_참() { - // given - LocalDateTime time = LocalDateTime.now(); - Festival festival = FestivalFixture.festival() - .startDate(time.plusDays(1).toLocalDate()) - .endDate(time.plusDays(4).toLocalDate()) - .build(); - - // when - boolean actual = festival.isNotInDuration(time); - - // then - assertThat(actual).isTrue(); - } - - @Test - void 축제_종료_일자_이후이면_참() { - // given - LocalDateTime time = LocalDateTime.now(); - Festival festival = FestivalFixture.festival() - .startDate(time.minusDays(4).toLocalDate()) - .endDate(time.minusDays(1).toLocalDate()) - .build(); - - // when - boolean actual = festival.isNotInDuration(time); - - // then - assertThat(actual).isTrue(); - } - - @Test - void 축제_기간중이면_거짓() { - // given - LocalDateTime time = LocalDateTime.now(); - Festival festival = FestivalFixture.festival() - .startDate(time.minusDays(1).toLocalDate()) - .endDate(time.toLocalDate()) - .build(); - - // when - boolean actual = festival.isNotInDuration(time); - - // then - assertThat(actual).isFalse(); - } - } -} diff --git a/backend/src/test/java/com/festago/domain/MemberTest.java b/backend/src/test/java/com/festago/domain/MemberTest.java deleted file mode 100644 index e88b98641..000000000 --- a/backend/src/test/java/com/festago/domain/MemberTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.festago.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.festago.member.domain.Member; -import com.festago.support.MemberFixture; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class MemberTest { - - @Test - void 프로필_이미지가_null이면_기본값이_할당된다() { - // when - Member actual = MemberFixture.member() - .profileImage(null) - .build(); - - // then - assertThat(actual.getProfileImage()).isNotNull(); - } -} diff --git a/backend/src/test/java/com/festago/domain/MemberTicketRepositoryTest.java b/backend/src/test/java/com/festago/domain/MemberTicketRepositoryTest.java deleted file mode 100644 index bafb2b838..000000000 --- a/backend/src/test/java/com/festago/domain/MemberTicketRepositoryTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.festago.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.festago.common.domain.BaseTimeEntity; -import com.festago.config.JpaAuditingConfig; -import com.festago.festival.domain.Festival; -import com.festago.festival.repository.FestivalRepository; -import com.festago.member.domain.Member; -import com.festago.member.repository.MemberRepository; -import com.festago.school.domain.School; -import com.festago.school.repository.SchoolRepository; -import com.festago.stage.domain.Stage; -import com.festago.stage.repository.StageRepository; -import com.festago.support.FestivalFixture; -import com.festago.support.MemberFixture; -import com.festago.support.MemberTicketFixture; -import com.festago.support.SchoolFixture; -import com.festago.support.StageFixture; -import com.festago.ticket.repository.TicketRepository; -import com.festago.ticketing.domain.MemberTicket; -import com.festago.ticketing.repository.MemberTicketRepository; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; - -@Import(JpaAuditingConfig.class) -@DataJpaTest -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class MemberTicketRepositoryTest { - - @Autowired - MemberTicketRepository memberTicketRepository; - - @Autowired - MemberRepository memberRepository; - - @Autowired - TicketRepository ticketRepository; - - @Autowired - StageRepository stageRepository; - - @Autowired - FestivalRepository festivalRepository; - - @Autowired - SchoolRepository schoolRepository; - - @Nested - class 회원의_ID로_에매한_티켓을_모두_조회 { - - @Test - void 성공() { - // given - Member member1 = memberRepository.save(MemberFixture.member().socialId("abc").build()); - Member member2 = memberRepository.save(MemberFixture.member().socialId("def").build()); - - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); - Stage stage1 = stageRepository.save(StageFixture.stage().festival(festival).build()); - Stage stage2 = stageRepository.save(StageFixture.stage().festival(festival).build()); - - memberTicketRepository.save(MemberTicketFixture.memberTicket().stage(stage1).owner(member1).build()); - memberTicketRepository.save(MemberTicketFixture.memberTicket().stage(stage2).owner(member1).build()); - memberTicketRepository.save(MemberTicketFixture.memberTicket().stage(stage1).owner(member2).build()); - - // when - List memberTickets = memberTicketRepository.findAllByOwnerId(member1.getId(), - PageRequest.of(0, 10)); - - // then - assertThat(memberTickets).hasSize(2); - } - - @Test - void 지정한_갯수만큼_조회() { - // given - int expected = 10; - Member member = memberRepository.save(MemberFixture.member().build()); - - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); - Stage stage = stageRepository.save(StageFixture.stage().festival(festival).build()); - - for (int i = 0; i < 20; i++) { - memberTicketRepository.save(MemberTicketFixture.memberTicket().stage(stage).owner(member).build()); - } - - // when - List memberTickets = memberTicketRepository.findAllByOwnerId(member.getId(), - PageRequest.of(0, expected)); - - // then - assertThat(memberTickets).hasSize(expected); - } - - @Test - void 지정한_정렬으로_조회() { - // given - Member member = memberRepository.save(MemberFixture.member().build()); - - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); - Stage stage = stageRepository.save(StageFixture.stage().festival(festival).build()); - - List memberTickets = new ArrayList<>(); - for (int i = 0; i < 20; i++) { - memberTickets.add(MemberTicketFixture.memberTicket().stage(stage).owner(member).build()); - } - memberTicketRepository.saveAll(memberTickets); - - Pageable pageable = PageRequest.of(0, 100, Sort.by("entryTime").descending()); - - // when - List actual = memberTicketRepository.findAllByOwnerId(member.getId(), pageable); - - // then - List expected = memberTickets.stream() - .sorted(Comparator.comparing(BaseTimeEntity::getCreatedAt).reversed()) - .toList(); - - assertThat(actual).isEqualTo(expected); - } - } -} diff --git a/backend/src/test/java/com/festago/domain/MemberTicketTest.java b/backend/src/test/java/com/festago/domain/MemberTicketTest.java deleted file mode 100644 index 9189725ca..000000000 --- a/backend/src/test/java/com/festago/domain/MemberTicketTest.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.festago.domain; - -import static com.festago.ticketing.domain.EntryState.AFTER_ENTRY; -import static com.festago.ticketing.domain.EntryState.AWAY; -import static com.festago.ticketing.domain.EntryState.BEFORE_ENTRY; -import static org.assertj.core.api.Assertions.assertThat; - -import com.festago.festival.domain.Festival; -import com.festago.member.domain.Member; -import com.festago.stage.domain.Stage; -import com.festago.support.FestivalFixture; -import com.festago.support.MemberTicketFixture; -import com.festago.support.StageFixture; -import com.festago.ticketing.domain.MemberTicket; -import java.time.LocalDateTime; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class MemberTicketTest { - - @Nested - class 입장_가능_여부_검사 { - - @Test - void 입장시간_전_입장_불가() { - // given - LocalDateTime entryTime = LocalDateTime.now(); - LocalDateTime time = entryTime.minusMinutes(10); - - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .entryTime(entryTime) - .build(); - - // when && then - assertThat(memberTicket.canEntry(time)).isFalse(); - } - - @Test - void 입장시간_24시간이_지나면_입장_불가() { - // given - LocalDateTime entryTime = LocalDateTime.now(); - LocalDateTime time = entryTime.plusHours(24); - - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .entryTime(entryTime) - .build(); - - // when && then - assertThat(memberTicket.canEntry(time)).isFalse(); - } - - @ParameterizedTest - @ValueSource(strings = {"2023-07-28T17:59:59", "2023-07-27T18:00:00"}) - void 입장_가능(LocalDateTime time) { - // given - LocalDateTime entryTime = LocalDateTime.parse("2023-07-27T18:00:00"); - Festival festival = FestivalFixture.festival() - .startDate(entryTime.toLocalDate()) - .endDate(entryTime.plusDays(4).toLocalDate()) - .build(); - Stage stage = StageFixture.stage() - .startTime(entryTime.plusHours(4)) - .ticketOpenTime(entryTime.minusWeeks(1)) - .festival(festival) - .build(); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .stage(stage) - .entryTime(entryTime) - .build(); - - // when && then - assertThat(memberTicket.canEntry(time)).isTrue(); - } - } - - @Nested - class 대기상태_티켓_검사 { - - @Test - void 입장시간_이후이면_거짓() { - // given - LocalDateTime entryTime = LocalDateTime.now(); - LocalDateTime time = entryTime.plusHours(1); - - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .entryTime(entryTime) - .build(); - - // when & then - assertThat(memberTicket.isBeforeEntry(time)).isFalse(); - } - - @Test - void 입장시간_이전이면_참() { - // given - LocalDateTime entryTime = LocalDateTime.now(); - LocalDateTime time = entryTime.minusHours(12).plusSeconds(1); - Festival festival = FestivalFixture.festival() - .startDate(entryTime.toLocalDate()) - .endDate(entryTime.plusDays(4).toLocalDate()) - .build(); - Stage stage = StageFixture.stage() - .startTime(entryTime.plusHours(4)) - .festival(festival) - .build(); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .stage(stage) - .entryTime(entryTime) - .build(); - - // when & then - assertThat(memberTicket.isBeforeEntry(time)).isTrue(); - } - } - - @Nested - class 출입_상태_변경 { - - @Test - void 상태_변경시_기존의_상태와_다르면_기존_상태가_유지된다() { - // given - MemberTicket memberTicket = MemberTicketFixture.memberTicket().build(); - - // when - memberTicket.changeState(AFTER_ENTRY); - - // then - assertThat(memberTicket.getEntryState()).isEqualTo(BEFORE_ENTRY); - } - - @Test - void 출입_전_상태에서_상태를_변경하면_출입_후_상태로_변경() { - // given - MemberTicket memberTicket = MemberTicketFixture.memberTicket().build(); - - // when - memberTicket.changeState(BEFORE_ENTRY); - - // then - assertThat(memberTicket.getEntryState()).isEqualTo(AFTER_ENTRY); - } - - @Test - void 출입_후_상태에서_상태를_변경하면_외출_상태로_변경() { - // given - MemberTicket memberTicket = MemberTicketFixture.memberTicket().build(); - memberTicket.changeState(BEFORE_ENTRY); - - // when - memberTicket.changeState(AFTER_ENTRY); - - // then - assertThat(memberTicket.getEntryState()).isEqualTo(AWAY); - } - - @Test - void 외출_상태에서_상태를_변경하면_출입_후_상태로_변경() { - // given - MemberTicket memberTicket = MemberTicketFixture.memberTicket().build(); - memberTicket.changeState(BEFORE_ENTRY); - memberTicket.changeState(AFTER_ENTRY); - - // when - memberTicket.changeState(AWAY); - - // then - assertThat(memberTicket.getEntryState()).isEqualTo(AFTER_ENTRY); - } - } - - @Nested - class 티켓_주인_검사 { - - @Test - void 티켓_주인이다() { - // given - Long memberId = 1L; - Member member = new Member(memberId); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .owner(member) - .build(); - - // when && then - assertThat(memberTicket.isOwner(memberId)).isTrue(); - } - - @Test - void 티켓_주인이_아니다() { - // given - Long memberId = 1L; - Long ownerId = 2L; - Member owner = new Member(ownerId); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .owner(owner) - .build(); - - // when && then - assertThat(memberTicket.isOwner(memberId)).isFalse(); - } - - } -} diff --git a/backend/src/test/java/com/festago/domain/StageRepositoryTest.java b/backend/src/test/java/com/festago/domain/StageRepositoryTest.java deleted file mode 100644 index c88bb4cf9..000000000 --- a/backend/src/test/java/com/festago/domain/StageRepositoryTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.festago.domain; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.SoftAssertions.assertSoftly; - -import com.festago.festival.domain.Festival; -import com.festago.festival.repository.FestivalRepository; -import com.festago.school.domain.School; -import com.festago.school.repository.SchoolRepository; -import com.festago.stage.domain.Stage; -import com.festago.stage.repository.StageRepository; -import com.festago.support.FestivalFixture; -import com.festago.support.SchoolFixture; -import com.festago.support.StageFixture; -import com.festago.support.TicketFixture; -import com.festago.ticket.domain.Ticket; -import com.festago.ticket.domain.TicketType; -import com.festago.ticket.repository.TicketRepository; -import jakarta.persistence.EntityManager; -import java.util.List; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; - -@DataJpaTest -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class StageRepositoryTest { - - @Autowired - FestivalRepository festivalRepository; - - @Autowired - StageRepository stageRepository; - - @Autowired - TicketRepository ticketRepository; - - @Autowired - SchoolRepository schoolRepository; - - @Autowired - EntityManager entityManager; - - @Test - void 티켓이_존재하지_않을때도_무대가_조회된다() { - // given - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); - Stage stage1 = stageRepository.save(StageFixture.stage().festival(festival).build()); - Stage stage2 = stageRepository.save(StageFixture.stage().festival(festival).build()); - - // when - List actual = stageRepository.findAllDetailByFestivalId(festival.getId()); - - // then - List stageIds = actual.stream() - .map(Stage::getId) - .toList(); - assertThat(stageIds).containsExactlyInAnyOrder(stage1.getId(), stage2.getId()); - } - - @Test - void 해당_축제의_무대가_모두_조회된다() { - // given - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); - stageRepository.save(StageFixture.stage().festival(festival).build()); - - // when - List actual = stageRepository.findAllDetailByFestivalId(festival.getId()); - - // then - assertThat(actual.size()).isEqualTo(1); - } - - @Test - void 티켓_정보까지_모두_조회된다() { - // given - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); - Stage stage = stageRepository.save(StageFixture.stage().festival(festival).build()); - Ticket ticket1 = ticketRepository.save( - TicketFixture.ticket().ticketType(TicketType.STUDENT).stage(stage).build()); - Ticket ticket2 = ticketRepository.save( - TicketFixture.ticket().ticketType(TicketType.VISITOR).stage(stage).build()); - ticket1.getTicketAmount().addTotalAmount(100); - ticket2.getTicketAmount().addTotalAmount(200); - entityManager.flush(); - entityManager.clear(); - - // when - List actual = stageRepository.findAllDetailByFestivalId(festival.getId()); - - // then - assertSoftly(softly -> { - softly.assertThat(actual.size()).isEqualTo(1); - Stage actualStage = actual.get(0); - softly.assertThat(actualStage.getId()).isEqualTo(stage.getId()); - List actualTickets = actualStage.getTickets(); - softly.assertThat(actualTickets.stream() - .map(Ticket::getTicketType) - .toList()) - .containsExactlyInAnyOrder(TicketType.STUDENT, TicketType.VISITOR); - softly.assertThat(actualTickets.stream() - .map(ticket -> ticket.getTicketAmount().getTotalAmount()) - .toList()) - .containsExactlyInAnyOrder(100, 200); - }); - } -} diff --git a/backend/src/test/java/com/festago/domain/TicketEntryTimeTest.java b/backend/src/test/java/com/festago/domain/TicketEntryTimeTest.java deleted file mode 100644 index 3c2025d6b..000000000 --- a/backend/src/test/java/com/festago/domain/TicketEntryTimeTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.festago.domain; - -import static com.festago.common.exception.ErrorCode.INVALID_MIN_TICKET_AMOUNT; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.festago.common.exception.BadRequestException; -import com.festago.ticket.domain.TicketEntryTime; -import java.time.LocalDateTime; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class TicketEntryTimeTest { - - @Test - void 총_수량이_음수일_경우_에외() { - // given - LocalDateTime now = LocalDateTime.now(); - - // when & then - assertThatThrownBy(() -> new TicketEntryTime(now, -1)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("TicketEntryTime 의 필드로 허용된 범위를 넘은 column 을 넣을 수 없습니다."); - } - - @Test - void 총_수량이_1_미만이면_예외() { - // given - LocalDateTime now = LocalDateTime.now(); - - // when & then - assertThatThrownBy(() -> new TicketEntryTime(now, 0)) - .isInstanceOf(BadRequestException.class) - .hasMessage(INVALID_MIN_TICKET_AMOUNT.getMessage()); - } -} diff --git a/backend/src/test/java/com/festago/domain/TicketRepositoryTest.java b/backend/src/test/java/com/festago/domain/TicketRepositoryTest.java deleted file mode 100644 index 9c9361b71..000000000 --- a/backend/src/test/java/com/festago/domain/TicketRepositoryTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.festago.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.festago.festival.domain.Festival; -import com.festago.festival.repository.FestivalRepository; -import com.festago.school.domain.School; -import com.festago.school.repository.SchoolRepository; -import com.festago.stage.domain.Stage; -import com.festago.stage.repository.StageRepository; -import com.festago.support.FestivalFixture; -import com.festago.support.SchoolFixture; -import com.festago.support.StageFixture; -import com.festago.support.TicketFixture; -import com.festago.ticket.domain.Ticket; -import com.festago.ticket.domain.TicketType; -import com.festago.ticket.repository.TicketRepository; -import java.util.List; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -@DataJpaTest -class TicketRepositoryTest { - - @Autowired - TicketRepository ticketRepository; - - @Autowired - FestivalRepository festivalRepository; - - @Autowired - StageRepository stageRepository; - - @Autowired - SchoolRepository schoolRepository; - - @Test - void 공연의_ID로_티켓을_모두_조회() { - // given - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival().school(school).build()); - Stage stage = stageRepository.save(StageFixture.stage().festival(festival).build()); - Stage otherStage = stageRepository.save(StageFixture.stage().festival(festival).build()); - - ticketRepository.save(TicketFixture.ticket().stage(stage).ticketType(TicketType.STUDENT).build()); - ticketRepository.save(TicketFixture.ticket().stage(stage).ticketType(TicketType.VISITOR).build()); - ticketRepository.save(TicketFixture.ticket().stage(otherStage).build()); - - // when - List actual = ticketRepository.findAllByStageId(stage.getId()); - - // then - assertThat(actual).hasSize(2); - } -} diff --git a/backend/src/test/java/com/festago/domain/TicketTest.java b/backend/src/test/java/com/festago/domain/TicketTest.java deleted file mode 100644 index d22c54593..000000000 --- a/backend/src/test/java/com/festago/domain/TicketTest.java +++ /dev/null @@ -1,224 +0,0 @@ -package com.festago.domain; - -import static com.festago.common.exception.ErrorCode.EARLY_TICKET_ENTRY_THAN_OPEN; -import static com.festago.common.exception.ErrorCode.EARLY_TICKET_ENTRY_TIME; -import static com.festago.common.exception.ErrorCode.INVALID_TICKET_CREATE_TIME; -import static com.festago.common.exception.ErrorCode.LATE_TICKET_ENTRY_TIME; -import static com.festago.common.exception.ErrorCode.TICKET_CANNOT_RESERVE_STAGE_START; -import static com.festago.common.exception.ErrorCode.TICKET_SOLD_OUT; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.assertj.core.api.SoftAssertions.assertSoftly; - -import com.festago.common.exception.BadRequestException; -import com.festago.festival.domain.Festival; -import com.festago.member.domain.Member; -import com.festago.stage.domain.Stage; -import com.festago.support.FestivalFixture; -import com.festago.support.MemberFixture; -import com.festago.support.StageFixture; -import com.festago.support.TicketFixture; -import com.festago.ticket.domain.Ticket; -import com.festago.ticketing.domain.MemberTicket; -import java.time.LocalDateTime; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class TicketTest { - - @Nested - class 입장시간_추가_검증 { - - @ParameterizedTest - @ValueSource(longs = {0, 1}) - void 입장시간이_티켓오픈시간_이전이면_예외(long minute) { - // given - LocalDateTime now = LocalDateTime.now(); - Stage stage = StageFixture.stage() - .startTime(now.plusDays(1)) - .ticketOpenTime(now) - .build(); - Ticket ticket = TicketFixture.ticket() - .stage(stage) - .build(); - - // when & then - assertThatThrownBy( - () -> ticket.addTicketEntryTime(now.minusMinutes(10), now.minusMinutes(minute), 100)) - .isInstanceOf(BadRequestException.class) - .hasMessage(EARLY_TICKET_ENTRY_THAN_OPEN.getMessage()); - } - - @ParameterizedTest - @ValueSource(longs = {0, 1}) - void 입장_시간이_축제_시작_시간보다_같거나_이후면_예외(long minute) { - // given - Ticket ticket = TicketFixture.ticket() - .build(); - - Stage stage = ticket.getStage(); - LocalDateTime stageStartTime = stage.getStartTime(); - LocalDateTime entryTime = stageStartTime.plusMinutes(minute); - LocalDateTime ticketOpenTime = stage.getTicketOpenTime(); - - // when & then - assertThatThrownBy(() -> ticket.addTicketEntryTime(ticketOpenTime.minusMinutes(10), entryTime, 100)) - .isInstanceOf(BadRequestException.class) - .hasMessage(LATE_TICKET_ENTRY_TIME.getMessage()); - } - - @Test - void 입장_시간이_공연_시작_12시간_이전이면_예외() { - // given - Ticket ticket = TicketFixture.ticket() - .build(); - - Stage stage = ticket.getStage(); - LocalDateTime stageStartTime = stage.getStartTime(); - LocalDateTime entryTime = stageStartTime.minusHours(12).minusSeconds(1); - LocalDateTime ticketOpenTime = stage.getTicketOpenTime(); - - // when & then - assertThatThrownBy(() -> ticket.addTicketEntryTime(ticketOpenTime.minusMinutes(10), entryTime, 100)) - .isInstanceOf(BadRequestException.class) - .hasMessage(EARLY_TICKET_ENTRY_TIME.getMessage()); - } - - @Test - void 티켓_오픈_이후_티켓생성시_예외() { - // given - Stage stage = StageFixture.stage() - .ticketOpenTime(LocalDateTime.now().minusHours(1)) - .build(); - Ticket ticket = TicketFixture.ticket() - .build(); - - LocalDateTime startTime = stage.getStartTime(); - - // when & then - assertThatThrownBy(() -> ticket.addTicketEntryTime(LocalDateTime.now(), startTime.minusHours(3), 100)) - .isInstanceOf(BadRequestException.class) - .hasMessage(INVALID_TICKET_CREATE_TIME.getMessage()); - } - - @Test - void 입장시간을_추가한다() { - // given - Ticket ticket = TicketFixture - .ticket() - .build(); - - Stage stage = ticket.getStage(); - LocalDateTime startTime = stage.getStartTime(); - LocalDateTime ticketOpenTime = stage.getTicketOpenTime(); - - // when - ticket.addTicketEntryTime(ticketOpenTime.minusMinutes(10), startTime.minusHours(3), 100); - ticket.addTicketEntryTime(ticketOpenTime.minusMinutes(10), startTime.minusHours(2), 200); - - // then - assertSoftly(softly -> { - softly.assertThat(ticket.getTicketAmount().getTotalAmount()).isEqualTo(300); - softly.assertThat(ticket.getTicketEntryTimes()).hasSize(2); - }); - } - } - - @Nested - class 예매_티켓_생성 { - - @Test - void 최대_수량보다_많으면_예외() { - // given - LocalDateTime stageStartTime = LocalDateTime.parse("2022-08-12T18:00:00"); - LocalDateTime now = stageStartTime.minusHours(6); - Festival festival = FestivalFixture.festival() - .startDate(stageStartTime.toLocalDate()) - .endDate(stageStartTime.toLocalDate()) - .build(); - Stage stage = StageFixture.stage() - .startTime(stageStartTime) - .ticketOpenTime(stageStartTime.minusDays(1)) - .festival(festival) - .build(); - Ticket ticket = TicketFixture.ticket() - .stage(stage) - .build(); - Member member = MemberFixture.member() - .id(1L) - .build(); - - // when & then - assertThatThrownBy(() -> ticket.createMemberTicket(member, 101, now)) - .isInstanceOf(BadRequestException.class) - .hasMessage(TICKET_SOLD_OUT.getMessage()); - } - - @Test - void 공연의_시간이_지나고_예매하면_예외() { - LocalDateTime stageStartTime = LocalDateTime.parse("2022-08-12T18:00:00"); - LocalDateTime now = stageStartTime.plusHours(1); - Festival festival = FestivalFixture.festival() - .startDate(stageStartTime.toLocalDate()) - .endDate(stageStartTime.toLocalDate()) - .build(); - Stage stage = StageFixture.stage() - .startTime(stageStartTime) - .ticketOpenTime(stageStartTime.minusDays(1)) - .festival(festival) - .build(); - Ticket ticket = TicketFixture.ticket() - .stage(stage) - .build(); - Member member = MemberFixture.member() - .id(1L) - .build(); - - ticket.addTicketEntryTime(LocalDateTime.MIN, stageStartTime.minusHours(1), 100); - - // when & then - assertThatThrownBy(() -> ticket.createMemberTicket(member, 1, now)) - .isInstanceOf(BadRequestException.class) - .hasMessage(TICKET_CANNOT_RESERVE_STAGE_START.getMessage()); - } - - @ParameterizedTest - @ValueSource(ints = {0, 100}) - void 성공(int reservationSequence) { - // given - LocalDateTime stageStartTime = LocalDateTime.parse("2022-08-12T18:00:00"); - LocalDateTime now = stageStartTime.minusHours(6); - Festival festival = FestivalFixture.festival() - .startDate(stageStartTime.toLocalDate()) - .endDate(stageStartTime.toLocalDate()) - .build(); - Stage stage = StageFixture.stage() - .startTime(stageStartTime) - .ticketOpenTime(stageStartTime.minusDays(1)) - .festival(festival) - .build(); - Ticket ticket = TicketFixture.ticket() - .stage(stage) - .build(); - Member member = MemberFixture.member() - .id(1L) - .build(); - - ticket.addTicketEntryTime(LocalDateTime.MIN, stageStartTime.minusHours(1), 50); - ticket.addTicketEntryTime(LocalDateTime.MIN, stageStartTime.minusHours(2), 30); - ticket.addTicketEntryTime(LocalDateTime.MIN, stageStartTime.minusHours(3), 20); - - // when - MemberTicket memberTicket = ticket.createMemberTicket(member, reservationSequence, now); - - // then - assertThat(memberTicket.getOwner()).isEqualTo(member); - } - } -} diff --git a/backend/src/test/java/com/festago/domain/VerificationCodeTest.java b/backend/src/test/java/com/festago/domain/VerificationCodeTest.java deleted file mode 100644 index 4cccf3fc4..000000000 --- a/backend/src/test/java/com/festago/domain/VerificationCodeTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.festago.domain; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.festago.common.exception.InternalServerException; -import com.festago.student.domain.VerificationCode; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class VerificationCodeTest { - - @Test - void null_이면_예외() { - // when & then - assertThatThrownBy(() -> new VerificationCode(null)) - .isInstanceOf(InternalServerException.class); - } - - @ParameterizedTest - @ValueSource(strings = {"12345", "1234567"}) - void 길이가_6자리가_아니면_예외(String code) { - // when & then - assertThatThrownBy(() -> new VerificationCode(code)) - .isInstanceOf(InternalServerException.class); - } - - @Test - void 숫자가_아니면_예외() { - // when & then - assertThatThrownBy(() -> new VerificationCode("일이삼사오육")) - .isInstanceOf(InternalServerException.class); - } - - @Test - void 음수이면_예외() { - // when & then - assertThatThrownBy(() -> new VerificationCode("-12345")) - .isInstanceOf(InternalServerException.class); - } - - @Test - void 생성() { - // when - VerificationCode verificationCode = new VerificationCode("123456"); - - // then - assertThat(verificationCode.getValue()).isEqualTo("123456"); - } -} diff --git a/backend/src/test/java/com/festago/dto/FestivalCreateRequestTest.java b/backend/src/test/java/com/festago/dto/FestivalCreateRequestTest.java deleted file mode 100644 index 9252f0d1c..000000000 --- a/backend/src/test/java/com/festago/dto/FestivalCreateRequestTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.festago.dto; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.festago.festival.domain.Festival; -import com.festago.festival.dto.FestivalCreateRequest; -import com.festago.school.domain.School; -import com.festago.support.SchoolFixture; -import java.time.LocalDate; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class FestivalCreateRequestTest { - - @Test - void 섬네일이_없는_요청은_이미지가_디폴트로_설정된다() { - // given - FestivalCreateRequest request = new FestivalCreateRequest( - "name", - LocalDate.now(), - LocalDate.now().plusDays(2L), - "", - 1L); - School school = SchoolFixture.school().build(); - - // when - Festival festival = request.toEntity(school); - - // then - assertThat(festival.getThumbnail()).isEqualTo("https://picsum.photos/536/354"); - } - - @Test - void 섬네일이_없는_요청은_이미지를_요청_정보로_생성한다() { - // given - FestivalCreateRequest request = new FestivalCreateRequest( - "name", - LocalDate.now(), - LocalDate.now().plusDays(2L), - "img", - 1L); - School school = SchoolFixture.school().build(); - - // when - Festival festival = request.toEntity(school); - - // then - assertThat(festival.getThumbnail()).isEqualTo("img"); - } - -} diff --git a/backend/src/test/java/com/festago/application/EntryServiceTest.java b/backend/src/test/java/com/festago/entry/application/EntryServiceTest.java similarity index 88% rename from backend/src/test/java/com/festago/application/EntryServiceTest.java rename to backend/src/test/java/com/festago/entry/application/EntryServiceTest.java index 8786f8de6..1538c9a6b 100644 --- a/backend/src/test/java/com/festago/application/EntryServiceTest.java +++ b/backend/src/test/java/com/festago/entry/application/EntryServiceTest.java @@ -1,4 +1,4 @@ -package com.festago.application; +package com.festago.entry.application; import static com.festago.common.exception.ErrorCode.MEMBER_TICKET_NOT_FOUND; import static com.festago.common.exception.ErrorCode.NOT_ENTRY_TIME; @@ -6,16 +6,14 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.anyLong; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.festago.common.exception.BadRequestException; import com.festago.common.exception.NotFoundException; -import com.festago.entry.application.EntryCodeManager; -import com.festago.entry.application.EntryService; import com.festago.entry.domain.EntryCode; import com.festago.entry.domain.EntryCodePayload; import com.festago.entry.dto.EntryCodeResponse; @@ -25,11 +23,11 @@ import com.festago.festival.domain.Festival; import com.festago.member.domain.Member; import com.festago.stage.domain.Stage; -import com.festago.support.FestivalFixture; -import com.festago.support.MemberFixture; -import com.festago.support.MemberTicketFixture; -import com.festago.support.StageFixture; import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.MemberTicketFixture; +import com.festago.support.fixture.StageFixture; import com.festago.ticketing.domain.EntryState; import com.festago.ticketing.domain.MemberTicket; import com.festago.ticketing.repository.MemberTicketRepository; @@ -50,9 +48,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -@ExtendWith(MockitoExtension.class) @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) class EntryServiceTest { @Mock @@ -77,16 +75,16 @@ class 티켓의_QR_생성_요청 { void 입장_시간_전_요청하면_예외() { // given LocalDateTime entryTime = LocalDateTime.parse("2023-07-30T16:00:00"); - Festival festival = FestivalFixture.festival() + Festival festival = FestivalFixture.builder() .startDate(entryTime.toLocalDate()) .endDate(entryTime.toLocalDate()) .build(); - Stage stage = StageFixture.stage() + Stage stage = StageFixture.builder() .festival(festival) .startTime(entryTime.plusHours(2)) .ticketOpenTime(entryTime.minusHours(1)) .build(); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() + MemberTicket memberTicket = MemberTicketFixture.builder() .id(1L) .stage(stage) .entryTime(entryTime) @@ -108,16 +106,16 @@ class 티켓의_QR_생성_요청 { void 입장_시간이_24시간이_넘은_경우_예외() { // given LocalDateTime entryTime = LocalDateTime.parse("2023-07-30T16:00:00"); - Festival festival = FestivalFixture.festival() + Festival festival = FestivalFixture.builder() .startDate(entryTime.toLocalDate()) .endDate(entryTime.toLocalDate()) .build(); - Stage stage = StageFixture.stage() + Stage stage = StageFixture.builder() .festival(festival) .startTime(entryTime.plusHours(2)) .ticketOpenTime(entryTime.minusHours(1)) .build(); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() + MemberTicket memberTicket = MemberTicketFixture.builder() .id(1L) .stage(stage) .entryTime(entryTime) @@ -139,10 +137,10 @@ class 티켓의_QR_생성_요청 { void 자신의_티켓이_아니면_예외() { // given Long memberId = 1L; - Member other = MemberFixture.member() + Member other = MemberFixture.builder() .id(2L) .build(); - MemberTicket otherTicket = MemberTicketFixture.memberTicket() + MemberTicket otherTicket = MemberTicketFixture.builder() .id(1L) .owner(other) .build(); @@ -175,16 +173,16 @@ class 티켓의_QR_생성_요청 { // given LocalDateTime entryTime = LocalDateTime.parse("2023-07-30T16:00:00"); Instant now = Instant.from(ZonedDateTime.of(entryTime, ZoneId.systemDefault())); - Festival festival = FestivalFixture.festival() + Festival festival = FestivalFixture.builder() .startDate(entryTime.toLocalDate()) .endDate(entryTime.toLocalDate()) .build(); - Stage stage = StageFixture.stage() + Stage stage = StageFixture.builder() .festival(festival) .startTime(entryTime.plusHours(2)) .ticketOpenTime(entryTime.minusHours(1)) .build(); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() + MemberTicket memberTicket = MemberTicketFixture.builder() .id(1L) .stage(stage) .entryTime(entryTime) @@ -218,7 +216,7 @@ class 티켓_검사 { void 예매한_티켓의_입장_상태와_요청의_입장_상태가_같으면_에매한_티켓의_입장_상태를_변경한다() { // given TicketValidationRequest request = new TicketValidationRequest("code"); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() + MemberTicket memberTicket = MemberTicketFixture.builder() .id(1L) .build(); given(entryCodeManager.extract(anyString())) @@ -240,7 +238,7 @@ class 티켓_검사 { void 예매한_티켓의_입장_상태와_요청의_입장_상태가_다르면_에매한_티켓의_입장_상태를_변경하지_않는다() { // given TicketValidationRequest request = new TicketValidationRequest("code"); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() + MemberTicket memberTicket = MemberTicketFixture.builder() .id(1L) .build(); given(entryCodeManager.extract(anyString())) diff --git a/backend/src/test/java/com/festago/entry/domain/EntryCodeTest.java b/backend/src/test/java/com/festago/entry/domain/EntryCodeTest.java new file mode 100644 index 000000000..b01ddcfb9 --- /dev/null +++ b/backend/src/test/java/com/festago/entry/domain/EntryCodeTest.java @@ -0,0 +1,50 @@ +package com.festago.entry.domain; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.festago.common.exception.UnexpectedException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class EntryCodeTest { + + @ParameterizedTest + @NullSource + @ValueSource(strings = {""}) + void 입장_코드의_코드가_null_또는_공백이면_예외(String code) { + assertThatThrownBy(() -> new EntryCode(code, 30, 10)) + .isInstanceOf(UnexpectedException.class) + .hasMessage("code는 빈 값 또는 null이 될 수 없습니다."); + } + + @ParameterizedTest + @ValueSource(longs = {0, -1}) + void 입장_코드의_수명이_0_또는_음수이면_예외(long period) { + // when & then + assertThatThrownBy(() -> new EntryCode("code", period, 0)) + .isInstanceOf(UnexpectedException.class) + .hasMessage("period는 0 또는 음수가 될 수 없습니다."); + } + + @Test + void 입장_코드의_오프셋이_음수이면_예외() { + // when & then + assertThatThrownBy(() -> new EntryCode("code", 30, -1)) + .isInstanceOf(UnexpectedException.class) + .hasMessage("offset은 음수가 될 수 없습니다."); + } + + @ParameterizedTest + @ValueSource(longs = {0, 1}) + void 입장_코드의_오프셋이_0이상이면_성공(long offset) { + assertThatNoException() + .isThrownBy(() -> new EntryCode("code", 30, offset)); + } +} diff --git a/backend/src/test/java/com/festago/infrastructure/JwtEntryCodeExtractorTest.java b/backend/src/test/java/com/festago/entry/infrastructure/JwtEntryCodeExtractorTest.java similarity index 89% rename from backend/src/test/java/com/festago/infrastructure/JwtEntryCodeExtractorTest.java rename to backend/src/test/java/com/festago/entry/infrastructure/JwtEntryCodeExtractorTest.java index cd362d593..c0e8d54c7 100644 --- a/backend/src/test/java/com/festago/infrastructure/JwtEntryCodeExtractorTest.java +++ b/backend/src/test/java/com/festago/entry/infrastructure/JwtEntryCodeExtractorTest.java @@ -1,16 +1,13 @@ -package com.festago.infrastructure; +package com.festago.entry.infrastructure; import static com.festago.common.exception.ErrorCode.EXPIRED_ENTRY_CODE; import static com.festago.common.exception.ErrorCode.INVALID_ENTRY_CODE; -import static com.festago.common.exception.ErrorCode.INVALID_ENTRY_CODE_PAYLOAD; -import static com.festago.common.exception.ErrorCode.INVALID_ENTRY_STATE_INDEX; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.festago.common.exception.BadRequestException; -import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.UnexpectedException; import com.festago.entry.domain.EntryCodePayload; -import com.festago.entry.infrastructure.JwtEntryCodeExtractor; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; @@ -98,8 +95,8 @@ class JwtEntryCodeExtractorTest { // when & then assertThatThrownBy(() -> jwtEntryCodeExtractor.extract(code)) - .isInstanceOf(InternalServerException.class) - .hasMessage(INVALID_ENTRY_CODE_PAYLOAD.getMessage()); + .isInstanceOf(UnexpectedException.class) + .hasMessage("memberTicketId는 null이 될 수 없습니다."); } @Test @@ -113,8 +110,8 @@ class JwtEntryCodeExtractorTest { // when & then assertThatThrownBy(() -> jwtEntryCodeExtractor.extract(code)) - .isInstanceOf(InternalServerException.class) - .hasMessage(INVALID_ENTRY_STATE_INDEX.getMessage()); + .isInstanceOf(UnexpectedException.class) + .hasMessage("entryState의 인덱스는 null이 될 수 없습니다."); } @Test diff --git a/backend/src/test/java/com/festago/entry/infrastructure/JwtEntryCodeProviderTest.java b/backend/src/test/java/com/festago/entry/infrastructure/JwtEntryCodeProviderTest.java new file mode 100644 index 000000000..0a4572b9e --- /dev/null +++ b/backend/src/test/java/com/festago/entry/infrastructure/JwtEntryCodeProviderTest.java @@ -0,0 +1,69 @@ +package com.festago.entry.infrastructure; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.common.exception.UnexpectedException; +import com.festago.entry.application.EntryCodeProvider; +import com.festago.entry.domain.EntryCodePayload; +import com.festago.support.fixture.MemberTicketFixture; +import com.festago.ticketing.domain.MemberTicket; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import java.util.Date; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class JwtEntryCodeProviderTest { + + private static final String SECRET_KEY = "1231231231231231223131231231231231231212312312"; + + EntryCodeProvider entryCodeProvider = new JwtEntryCodeProvider(SECRET_KEY); + + @Test + void expiredAt이_과거이면_예외() { + // given + Date now = new Date(); + Date expiredAt = new Date(now.getTime() - 1_000); + MemberTicket memberTicket = MemberTicketFixture.builder() + .id(1L) + .build(); + EntryCodePayload entryCodePayload = EntryCodePayload.from(memberTicket); + + // when & then + assertThatThrownBy(() -> entryCodeProvider.provide(entryCodePayload, expiredAt)) + .isInstanceOf(UnexpectedException.class) + .hasMessage("입장코드 만료일자는 과거일 수 없습니다."); + } + + @Test + void JWT_토큰을_생성() { + // given + long period = 30_000; + MemberTicket memberTicket = MemberTicketFixture.builder().id(1L).build(); + Date now = new Date(); + Date expiredAt = new Date(now.getTime() + period); + EntryCodePayload entryCodePayload = EntryCodePayload.from(memberTicket); + + // when + String code = entryCodeProvider.provide(entryCodePayload, expiredAt); + + // then + Claims claims = Jwts.parser() + .setSigningKey(SECRET_KEY.getBytes()) + .build() + .parseSignedClaims(code) + .getPayload(); + + Long actualMemberTicketId = claims.get("ticketId", Long.class); + Date actualExpiredAt = claims.getExpiration(); + + assertSoftly(softly -> { + softly.assertThat(actualExpiredAt).isEqualToIgnoringMillis(expiredAt); + softly.assertThat(actualMemberTicketId).isEqualTo(memberTicket.getId()); + }); + } +} diff --git a/backend/src/test/java/com/festago/entry/presentation/MemberEntranceControllerTest.java b/backend/src/test/java/com/festago/entry/presentation/MemberEntranceControllerTest.java new file mode 100644 index 000000000..494eab4ca --- /dev/null +++ b/backend/src/test/java/com/festago/entry/presentation/MemberEntranceControllerTest.java @@ -0,0 +1,49 @@ +package com.festago.entry.presentation; + +import static org.mockito.BDDMockito.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.entry.application.EntryService; +import com.festago.entry.dto.EntryCodeResponse; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberEntranceControllerTest { + + private static final String TOKEN = "token"; + + @Autowired + MockMvc mockMvc; + + @Autowired + EntryService entryService; + + @Test + @WithMockAuth + void QR을_생성한다() throws Exception { + // given + Long memberTicketId = 1L; + String code = "code"; + long period = 30L; + + given(entryService.createEntryCode(anyLong(), anyLong())) + .willReturn(new EntryCodeResponse(code, period)); + + // when & then + mockMvc.perform(post("/member-tickets/{memberTicketId}/qr", memberTicketId) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + TOKEN)) + .andExpect(status().isOk()); + } +} diff --git a/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java b/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java index 03b422833..ca6648a77 100644 --- a/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java +++ b/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java @@ -1,96 +1,59 @@ package com.festago.fcm.application; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.festago.entry.dto.event.EntryProcessEvent; -import com.festago.fcm.dto.MemberFCMResponse; -import com.festago.fcm.dto.MemberFCMsResponse; -import com.google.firebase.messaging.BatchResponse; -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.SendResponse; -import java.util.List; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; 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.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.transaction.TestTransaction; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") -@ExtendWith(MockitoExtension.class) class FCMNotificationEventListenerTest { - @Mock - FirebaseMessaging firebaseMessaging; - - @Mock - MemberFCMService memberFCMService; + @Autowired + ApplicationEventPublisher eventPublisher; - @InjectMocks - FCMNotificationEventListener FCMNotificationEventListener; + @MockBean + FCMNotificationEventListener fcmNotificationEventListener; @Test - void 유저의_모든_FCM_요청이_성공() throws FirebaseMessagingException { + @Transactional + void 이벤트를_발행하고_트랜잭션이_커밋되면_이벤트_수신() { // given - given(memberFCMService.findMemberFCM(anyLong())).willReturn( - new MemberFCMsResponse(List.of(new MemberFCMResponse(1L, 1L, "token1"), - new MemberFCMResponse(2L, 1L, "token2")))); - BatchResponse mockBatchResponse = mock(BatchResponse.class); - given(mockBatchResponse.getFailureCount()) - .willReturn(0); - - given(firebaseMessaging.sendAll(any())).willReturn(mockBatchResponse); - EntryProcessEvent event = new EntryProcessEvent(1L); + eventPublisher.publishEvent(new EntryProcessEvent(1L)); // when - FCMNotificationEventListener.sendFcmNotification(event); + TestTransaction.flagForCommit(); + TestTransaction.end(); // then - verify(mockBatchResponse, times(1)) - .getFailureCount(); - verify(mockBatchResponse, never()) - .getResponses(); + verify(fcmNotificationEventListener, times(1)) + .sendFcmNotification(any(EntryProcessEvent.class)); } @Test - void 유저의_FCM_요청_중_하나라도_실패하면_예외() throws FirebaseMessagingException { + @Transactional + void 이벤트를_발행하고_트랜잭션이_롤백되면_이벤트_수신_하지않음() { // given - given(memberFCMService.findMemberFCM(anyLong())).willReturn( - new MemberFCMsResponse(List.of(new MemberFCMResponse(1L, 1L, "token1"), - new MemberFCMResponse(2L, 1L, "token2")))); - - BatchResponse mockBatchResponse = mock(BatchResponse.class); - SendResponse mockSendResponse = mock(SendResponse.class); - - given(mockSendResponse.isSuccessful()) - .willReturn(false); - given(mockBatchResponse.getFailureCount()) - .willReturn(1); - given(mockBatchResponse.getResponses()) - .willReturn(List.of(mockSendResponse)); - - given(firebaseMessaging.sendAll(any())).willReturn(mockBatchResponse); - - EntryProcessEvent event = new EntryProcessEvent(1L); + eventPublisher.publishEvent(new EntryProcessEvent(1L)); // when - FCMNotificationEventListener.sendFcmNotification(event); + TestTransaction.flagForRollback(); + TestTransaction.end(); // then - verify(mockBatchResponse, times(1)) - .getFailureCount(); - verify(mockBatchResponse, times(1)) - .getResponses(); - verify(mockSendResponse, times(1)) - .isSuccessful(); + verify(fcmNotificationEventListener, never()) + .sendFcmNotification(any(EntryProcessEvent.class)); } } diff --git a/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java b/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java index f4a65a20d..f5cea680f 100644 --- a/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java +++ b/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java @@ -3,73 +3,71 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import com.festago.auth.application.AuthExtractor; -import com.festago.auth.domain.AuthPayload; -import com.festago.auth.domain.Role; import com.festago.fcm.domain.MemberFCM; -import com.festago.fcm.dto.MemberFCMResponse; import com.festago.fcm.repository.MemberFCMRepository; +import com.festago.member.repository.MemberRepository; import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; 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; +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") @ExtendWith(MockitoExtension.class) class MemberFCMServiceTest { + @InjectMocks + MemberFCMService memberFCMService; + @Mock MemberFCMRepository memberFCMRepository; @Mock - AuthExtractor authExtractor; - - @InjectMocks - MemberFCMService memberFCMService; + MemberRepository memberRepository; @Test void 유저의_FCM_정보를_가져온다() { // given - List memberFCMS = List.of( + List memberFCMs = List.of( new MemberFCM(1L, 1L, "token"), new MemberFCM(2L, 1L, "token2") ); - given(memberFCMRepository.findByMemberId(anyLong())) - .willReturn(memberFCMS); + given(memberFCMRepository.findAllByMemberId(anyLong())) + .willReturn(memberFCMs); - List expect = memberFCMS.stream() - .map(MemberFCMResponse::from) - .collect(Collectors.toList()); + List expect = memberFCMs.stream() + .map(MemberFCM::getFcmToken) + .toList(); // when - List actual = memberFCMService.findMemberFCM(1L).memberFCMs(); + List actual = memberFCMService.findAllMemberFCMTokens(1L); // then - assertThat(actual).isEqualTo(expect); + assertThat(actual).containsAll(expect); } @Test - void 기존_유저의_새로운_FCM_토큰이라면_저장() { + void 유저의_새로운_FCM_토큰이라면_저장() { // given - String accessToken = "accessToken"; String fcmToken = "fcmToken"; - boolean isNewMember = false; Long memberId = 1L; - given(authExtractor.extract(any())) - .willReturn(new AuthPayload(memberId, Role.MEMBER)); - given(memberFCMRepository.findMemberFCMByMemberIdAndFcmToken(memberId, fcmToken)) - .willReturn(Optional.empty()); + given(memberRepository.existsById(anyLong())) + .willReturn(true); + given(memberFCMRepository.existsByMemberIdAndFcmToken(anyLong(), anyString())) + .willReturn(false); // when - memberFCMService.saveMemberFCM(isNewMember, accessToken, fcmToken); + memberFCMService.saveMemberFCM(memberId, fcmToken); // then verify(memberFCMRepository, times(1)) @@ -77,40 +75,20 @@ class MemberFCMServiceTest { } @Test - void 기존_유저의_이미_존재하는_유저의_FCM_토큰이라면_저장하지_않는다() { + void 유저의_FCM_토큰이_존재하면_저장하지_않는다() { // given - String accessToken = "accessToken"; - String originToken = "fcmToken"; - boolean isNewMember = false; - Long memberId = 1L; - given(authExtractor.extract(any())) - .willReturn(new AuthPayload(memberId, Role.MEMBER)); - given(memberFCMRepository.findMemberFCMByMemberIdAndFcmToken(memberId, originToken)) - .willReturn(Optional.of(new MemberFCM(memberId, originToken))); - - // when - memberFCMService.saveMemberFCM(isNewMember, accessToken, originToken); - - // then - verify(memberFCMRepository, never()) - .save(any(MemberFCM.class)); - } - - @Test - void 새로운_유저의_FCM_토큰을_저장한다() { - // given - String accessToken = "accessToken"; String fcmToken = "fcmToken"; - boolean isNewMember = false; Long memberId = 1L; - given(authExtractor.extract(any())) - .willReturn(new AuthPayload(memberId, Role.MEMBER)); + given(memberRepository.existsById(anyLong())) + .willReturn(true); + given(memberFCMRepository.existsByMemberIdAndFcmToken(anyLong(), anyString())) + .willReturn(true); // when - memberFCMService.saveMemberFCM(isNewMember, accessToken, fcmToken); + memberFCMService.saveMemberFCM(memberId, fcmToken); // then - verify(memberFCMRepository, times(1)) + verify(memberFCMRepository, never()) .save(any(MemberFCM.class)); } @@ -120,7 +98,7 @@ class MemberFCMServiceTest { Long memberId = 1L; // when - memberFCMService.deleteMemberFCM(memberId); + memberFCMService.deleteAllMemberFCM(memberId); // then verify(memberFCMRepository, times(1)) diff --git a/backend/src/test/java/com/festago/fcm/domain/MemberFCMTest.java b/backend/src/test/java/com/festago/fcm/domain/MemberFCMTest.java new file mode 100644 index 000000000..194885c73 --- /dev/null +++ b/backend/src/test/java/com/festago/fcm/domain/MemberFCMTest.java @@ -0,0 +1,54 @@ +package com.festago.fcm.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.ValidException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberFCMTest { + + @Test + void MemberFCM_생성_성공() { + // given + MemberFCM memberFCM = new MemberFCM(1L, "token"); + + // when & then + assertThat(memberFCM.getMemberId()).isEqualTo(1L); + assertThat(memberFCM.getFcmToken()).isEqualTo("token"); + } + + @ParameterizedTest + @NullSource + void memberId가_null이면_예외(Long memberId) { + // when & then + assertThatThrownBy(() -> new MemberFCM(memberId, "token")) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void token이_null_또는_공백이면_예외(String token) { + // when & then + assertThatThrownBy(() -> new MemberFCM(1L, token)) + .isInstanceOf(ValidException.class); + } + + @Test + void token의_길이가_255자를_초과하면_예외() { + // given + String token = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> new MemberFCM(1L, token)) + .isInstanceOf(ValidException.class); + } +} diff --git a/backend/src/test/java/com/festago/fcm/presentation/MemberFCMControllerTest.java b/backend/src/test/java/com/festago/fcm/presentation/MemberFCMControllerTest.java new file mode 100644 index 000000000..4a35c2a79 --- /dev/null +++ b/backend/src/test/java/com/festago/fcm/presentation/MemberFCMControllerTest.java @@ -0,0 +1,46 @@ +package com.festago.fcm.presentation; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.fcm.application.MemberFCMService; +import com.festago.fcm.dto.MemberFcmCreateRequest; +import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@CustomWebMvcTest +class MemberFCMControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + MemberFCMService memberFCMService; + + @Test + @WithMockAuth + void 사용자_FCM_토큰_저장() throws Exception { + // given + MemberFcmCreateRequest request = new MemberFcmCreateRequest("token1"); + + // when & then + mockMvc.perform(post("/member-fcm") + .header(HttpHeaders.AUTHORIZATION, "Bearer token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} diff --git a/backend/src/test/java/com/festago/fcm/repository/MemberFCMRepositoryTest.java b/backend/src/test/java/com/festago/fcm/repository/MemberFCMRepositoryTest.java index 3ea135724..5d6420a8a 100644 --- a/backend/src/test/java/com/festago/fcm/repository/MemberFCMRepositoryTest.java +++ b/backend/src/test/java/com/festago/fcm/repository/MemberFCMRepositoryTest.java @@ -2,16 +2,16 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.festago.auth.domain.SocialType; import com.festago.fcm.domain.MemberFCM; import com.festago.member.domain.Member; import com.festago.member.repository.MemberRepository; +import com.festago.support.RepositoryTest; +import com.festago.support.fixture.MemberFixture; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -@DataJpaTest +@RepositoryTest class MemberFCMRepositoryTest { @Autowired @@ -21,16 +21,17 @@ class MemberFCMRepositoryTest { MemberRepository memberRepository; @Test - void member_의_MemberFCM_을_찾을_수_있다() { + void member의_모든_fcmToken_조회() { // given - Member member = memberRepository.save(new Member("socialId", SocialType.FESTAGO, "nickname", "image.jpg")); + Member member = memberRepository.save(MemberFixture.builder().build()); Long memberId = member.getId(); - MemberFCM expect = memberFCMRepository.save(new MemberFCM(memberId, "fcmToken")); + MemberFCM expect1 = memberFCMRepository.save(new MemberFCM(memberId, "fcmToken")); + MemberFCM expect2 = memberFCMRepository.save(new MemberFCM(memberId, "fcmToken2")); // when - List actual = memberFCMRepository.findByMemberId(memberId); + List actual = memberFCMRepository.findAllByMemberId(memberId); // then - assertThat(actual).contains(expect); + assertThat(actual).containsExactly(expect1, expect2); } } diff --git a/backend/src/test/java/com/festago/festival/application/FestivalQueryInfoArtistRenewServiceTest.java b/backend/src/test/java/com/festago/festival/application/FestivalQueryInfoArtistRenewServiceTest.java new file mode 100644 index 000000000..7e0a83327 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/FestivalQueryInfoArtistRenewServiceTest.java @@ -0,0 +1,76 @@ +package com.festago.festival.application; + +import static java.util.stream.Collectors.joining; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.reset; + +import com.festago.artist.domain.Artist; +import com.festago.artist.domain.ArtistsSerializer; +import com.festago.festival.domain.FestivalIdStageArtistsResolver; +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.MemoryFestivalQueryInfoRepository; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalQueryInfoFixture; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalQueryInfoArtistRenewServiceTest { + + FestivalInfoRepository festivalInfoRepository; + FestivalIdStageArtistsResolver festivalIdStageArtistsResolver = mock(); + ArtistsSerializer artistsSerializer = artists -> artists.stream() + .map(Artist::getName) + .collect(joining(",")); // "뉴진스,에픽하이" + FestivalQueryInfoArtistRenewService festivalQueryInfoArtistRenewService; + + @BeforeEach + void setUp() { + festivalInfoRepository = new MemoryFestivalQueryInfoRepository(); + festivalQueryInfoArtistRenewService = new FestivalQueryInfoArtistRenewService( + festivalInfoRepository, + festivalIdStageArtistsResolver, + artistsSerializer + ); + reset(festivalIdStageArtistsResolver); + } + + @Nested + class renewArtistInfo { + + private final Long festivalId = 1L; + + private final Artist 뉴진스 = ArtistFixture.builder().id(1L).name("뉴진스").build(); + private final Artist 소녀시대 = ArtistFixture.builder().id(2L).name("소녀시대").build(); + FestivalQueryInfo festivalQueryInfo; + + @BeforeEach + void setUp() { + festivalQueryInfo = festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(festivalId).build()); + } + + @Test + void 기존의_ArtistInfo가_갱신된다() { + // given + given(festivalIdStageArtistsResolver.resolve(anyLong())) + .willReturn(List.of(뉴진스, 소녀시대)); + festivalQueryInfo.updateArtistInfo(List.of(뉴진스), artistsSerializer); + + // when + festivalQueryInfoArtistRenewService.renewArtistInfo(festivalId); + + // then + FestivalQueryInfo actual = festivalInfoRepository.findByFestivalId(festivalId).get(); + assertThat(actual.getArtistInfo()).isEqualTo("뉴진스,소녀시대"); + } + } +} diff --git a/backend/src/test/java/com/festago/festival/application/QueryDslSchoolSearchRecentFestivalV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/festival/application/QueryDslSchoolSearchRecentFestivalV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..909dba7f6 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/QueryDslSchoolSearchRecentFestivalV1QueryServiceIntegrationTest.java @@ -0,0 +1,157 @@ +package com.festago.festival.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class QueryDslSchoolSearchRecentFestivalV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + QueryDslSchoolUpcomingFestivalStartDateV1QueryService schoolUpcomingFestivalStartDateV1QueryService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + Clock clock; + + School 테코대학교; + + School 우테대학교; + + LocalDate _6월_14일 = LocalDate.parse("2077-06-14"); + LocalDate _6월_15일 = LocalDate.parse("2077-06-15"); + LocalDate _6월_16일 = LocalDate.parse("2077-06-16"); + LocalDate _6월_17일 = LocalDate.parse("2077-06-17"); + LocalDate _6월_18일 = LocalDate.parse("2077-06-18"); + + /** + * 테코대학교에 6월 15일 ~ 6월 15일 축제, 6월 16일 ~ 6월 16일 축제 우테대학교에 6월 16일 ~ 6월 17일 축제 + */ + @BeforeEach + void setUp() { + 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + 우테대학교 = schoolRepository.save(SchoolFixture.builder().name("우테대학교").build()); + festivalRepository.save(FestivalFixture.builder() + .name("테코대학교 6월 15일 당일 축제") + .startDate(_6월_15일) + .endDate(_6월_15일) + .school(테코대학교) + .build() + ); + festivalRepository.save(FestivalFixture.builder() + .name("테코대학교 6월 16일 당일 축제") + .startDate(_6월_16일) + .endDate(_6월_16일) + .school(테코대학교) + .build() + ); + festivalRepository.save(FestivalFixture.builder() + .name("우테대학교 6월 16~17일 축제") + .startDate(_6월_16일) + .endDate(_6월_17일) + .school(우테대학교) + .build() + ); + } + + @Test + void 오늘이_6월_14일_일때_테코대학교는_6월_15일_우테대학교는_6월_16일이_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_14일)); + + // when + var actual = schoolUpcomingFestivalStartDateV1QueryService.getSchoolIdToUpcomingFestivalStartDate( + List.of(테코대학교.getId(), 우테대학교.getId()) + ); + + // then + assertThat(actual.get(테코대학교.getId())).isEqualTo(_6월_15일); + assertThat(actual.get(우테대학교.getId())).isEqualTo(_6월_16일); + } + + @Test + void 오늘이_6월_15일_일때_테코대학교는_6월_15일_우테대학교는_6월_16일_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_15일)); + + // when + var actual = schoolUpcomingFestivalStartDateV1QueryService.getSchoolIdToUpcomingFestivalStartDate( + List.of(테코대학교.getId(), 우테대학교.getId()) + ); + + // then + assertThat(actual.get(테코대학교.getId())).isEqualTo(_6월_15일); + assertThat(actual.get(우테대학교.getId())).isEqualTo(_6월_16일); + } + + @Test + void 오늘이_6월_16일_일때_테코대학교는_6월_16일_우테대학교는_6월_16일_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_16일)); + + // when + var actual = schoolUpcomingFestivalStartDateV1QueryService.getSchoolIdToUpcomingFestivalStartDate( + List.of(테코대학교.getId(), 우테대학교.getId()) + ); + + // then + assertThat(actual.get(테코대학교.getId())).isEqualTo(_6월_16일); + assertThat(actual.get(우테대학교.getId())).isEqualTo(_6월_16일); + } + + @Test + void 오늘이_6월_17일_일때_테코대학교는_null_우테대학교는_6월_16일_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_17일)); + + // when + var actual = schoolUpcomingFestivalStartDateV1QueryService.getSchoolIdToUpcomingFestivalStartDate( + List.of(테코대학교.getId(), 우테대학교.getId()) + ); + + // then + assertThat(actual.get(테코대학교.getId())).isNull(); + assertThat(actual.get(우테대학교.getId())).isEqualTo(_6월_16일); + } + + @Test + void 오늘이_6월_18일_일때_테코대학교는_null_우테대학교는_null_조회된다() { + // given + given(clock.instant()) + .willReturn(TimeInstantProvider.from(_6월_18일)); + + // when + var actual = schoolUpcomingFestivalStartDateV1QueryService.getSchoolIdToUpcomingFestivalStartDate( + List.of(테코대학교.getId(), 우테대학교.getId()) + ); + + // then + assertThat(actual.get(테코대학교.getId())).isNull(); + assertThat(actual.get(우테대학교.getId())).isNull(); + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/command/FestivalCreateServiceTest.java b/backend/src/test/java/com/festago/festival/application/integration/command/FestivalCreateServiceTest.java new file mode 100644 index 000000000..feb784cdd --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/command/FestivalCreateServiceTest.java @@ -0,0 +1,106 @@ +package com.festago.festival.application.integration.command; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.application.command.FestivalCreateService; +import com.festago.festival.dto.command.FestivalCreateCommand; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.SchoolFixture; +import java.time.Clock; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalCreateServiceTest extends ApplicationIntegrationTest { + + @Autowired + FestivalCreateService festivalCreateService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + Clock clock; + + @Nested + class createFestival { + + Long schoolId; + LocalDate now = LocalDate.parse("2023-01-31"); + + @BeforeEach + void setUp() { + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + School school = schoolRepository.save(SchoolFixture.builder().build()); + schoolId = school.getId(); + } + + @Test + void 축제의_시작일이_현재_시간보다_과거이면_예외가_발생한다() { + // given + String festivalName = "테코대학교 축제"; + LocalDate startDate = now.minusDays(1); + LocalDate endDate = now.plusDays(3); + String posterImageUrl = "https://image.com/image.png"; + var command = FestivalCreateCommand.builder() + .name(festivalName) + .startDate(startDate) + .endDate(endDate) + .posterImageUrl(posterImageUrl) + .schoolId(schoolId) + .build(); + + // when & then + assertThatThrownBy(() -> festivalCreateService.createFestival(command)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_FESTIVAL_START_DATE.getMessage()); + } + + @Test + void 축제를_생성하면_축제가_저장되고_FestivalQueryInfo도_저장된다() { + // given + String festivalName = "테코대학교 축제"; + LocalDate startDate = now.plusDays(1); + LocalDate endDate = now.plusDays(3); + String posterImageUrl = "https://image.com/image.png"; + var command = FestivalCreateCommand.builder() + .name(festivalName) + .startDate(startDate) + .endDate(endDate) + .posterImageUrl(posterImageUrl) + .schoolId(schoolId) + .build(); + + // when + Long festivalId = festivalCreateService.createFestival(command); + + // then + assertSoftly(softly -> { + softly.assertThat(festivalRepository.findById(festivalId)).isPresent(); + softly.assertThat(festivalInfoRepository.findByFestivalId(festivalId)).isPresent(); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/command/FestivalDeleteServiceTest.java b/backend/src/test/java/com/festago/festival/application/integration/command/FestivalDeleteServiceTest.java new file mode 100644 index 000000000..6fe503ba2 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/command/FestivalDeleteServiceTest.java @@ -0,0 +1,107 @@ +package com.festago.festival.application.integration.command; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.application.command.FestivalDeleteService; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.FestivalQueryInfoFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalDeleteServiceTest extends ApplicationIntegrationTest { + + @Autowired + FestivalDeleteService festivalDeleteService; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + Clock clock; + + @Nested + class deleteFestival { + + Long festivalId; + LocalDate now = LocalDate.parse("2023-01-31"); + + @BeforeEach + void setUp() { + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder() + .startDate(now) + .endDate(now) + .school(school) + .build() + ); + festivalId = festival.getId(); + } + + @Test + void 공연이_등록된_축제는_삭제할_수_없다() { + // given + Festival festival = festivalRepository.getOrThrow(festivalId); + LocalDateTime startTime = LocalDateTime.now(clock); + LocalDateTime ticketOpenTime = startTime.minusDays(1); + stageRepository.save(StageFixture.builder() + .festival(festival) + .startTime(startTime) + .ticketOpenTime(ticketOpenTime) + .build() + ); + + assertThatThrownBy(() -> festivalDeleteService.deleteFestival(festivalId)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.FESTIVAL_DELETE_CONSTRAINT_EXISTS_STAGE.getMessage()); + } + + @Test + void 축제를_삭제하면_FestivalQueryInfo도_삭제된다() { + // given + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(festivalId).build()); + + // when + festivalDeleteService.deleteFestival(festivalId); + + // then + assertSoftly(softly -> { + softly.assertThat(festivalRepository.findById(festivalId)).isEmpty(); + softly.assertThat(festivalInfoRepository.findByFestivalId(festivalId)).isEmpty(); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/command/FestivalUpdateServiceTest.java b/backend/src/test/java/com/festago/festival/application/integration/command/FestivalUpdateServiceTest.java new file mode 100644 index 000000000..b5357fb6d --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/command/FestivalUpdateServiceTest.java @@ -0,0 +1,110 @@ +package com.festago.festival.application.integration.command; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; + +import com.festago.festival.application.command.FestivalUpdateService; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.command.FestivalUpdateCommand; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.Clock; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalUpdateServiceTest extends ApplicationIntegrationTest { + + @Autowired + FestivalUpdateService festivalUpdateService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + Clock clock; + + @Nested + class updateFestival { + + Long festivalId; + LocalDate now = LocalDate.parse("2023-01-31"); + + @BeforeEach + void setUp() { + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + festivalId = festival.getId(); + } + + @Test + void 시작일이_현재_시간보다_과거여도_수정할_수_있다() { + // given + String newFestivalName = "변경된 축제"; + LocalDate newStartDate = now.minusDays(1); + LocalDate newEndDate = now.plusDays(1); + String newPosterImageUrl = "https://image.com/new-image.png"; + var command = FestivalUpdateCommand.builder() + .name(newFestivalName) + .startDate(newStartDate) + .endDate(newEndDate) + .posterImageUrl(newPosterImageUrl) + .build(); + + // when + festivalUpdateService.updateFestival(festivalId, command); + + // then + Festival updatedFestival = festivalRepository.getOrThrow(festivalId); + assertSoftly(softly -> { + softly.assertThat(updatedFestival.getName()).isEqualTo(newFestivalName); + softly.assertThat(updatedFestival.getStartDate()).isEqualTo(newStartDate); + softly.assertThat(updatedFestival.getEndDate()).isEqualTo(newEndDate); + softly.assertThat(updatedFestival.getPosterImageUrl()).isEqualTo(newPosterImageUrl); + }); + } + + @Test + void 축제를_수정할_수_있다() { + // given + String newFestivalName = "변경된 축제"; + LocalDate newStartDate = now.plusDays(1); + LocalDate newEndDate = now.plusDays(1); + String newPosterImageUrl = "https://image.com/new-image.png"; + var command = FestivalUpdateCommand.builder() + .name(newFestivalName) + .startDate(newStartDate) + .endDate(newEndDate) + .posterImageUrl(newPosterImageUrl) + .build(); + + // when + festivalUpdateService.updateFestival(festivalId, command); + + // then + Festival updatedFestival = festivalRepository.getOrThrow(festivalId); + assertSoftly(softly -> { + softly.assertThat(updatedFestival.getName()).isEqualTo(newFestivalName); + softly.assertThat(updatedFestival.getStartDate()).isEqualTo(newStartDate); + softly.assertThat(updatedFestival.getEndDate()).isEqualTo(newEndDate); + softly.assertThat(updatedFestival.getPosterImageUrl()).isEqualTo(newPosterImageUrl); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/query/FestivalDetailV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/festival/application/integration/query/FestivalDetailV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..2c087b700 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/query/FestivalDetailV1QueryServiceIntegrationTest.java @@ -0,0 +1,227 @@ +package com.festago.festival.application.integration.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.application.FestivalDetailV1QueryService; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.SocialMediaV1Response; +import com.festago.festival.dto.StageV1Response; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.repository.SocialMediaRepository; +import com.festago.stage.application.command.StageCreateService; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.SocialMediaFixture; +import com.festago.support.fixture.StageArtistFixture; +import com.festago.support.fixture.StageFixture; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalDetailV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + FestivalDetailV1QueryService festivalDetailV1QueryService; + + @Autowired + SocialMediaRepository socialMediaRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StageCreateService stageCreateService; + + @Autowired + StageRepository stageRepository; + + @Autowired + StageArtistRepository stageArtistRepository; + + @Autowired + ArtistRepository artistRepository; + + LocalDate now = LocalDate.parse("2077-06-30"); + + Festival 테코대학교_축제; + Festival 테코대학교_공연_없는_축제; + Festival 우테대학교_축제; + + /** + * 테코대학교 축제는 공연이 있는 3일 기간의 축제와 공연이 없는 당일 축제가 있다.
테코대학교는 소셜미디어에 인스타그램과 페이스북이 등록되어 있다.

우테대학교 축제는 공연이 + * 있는 당일 축제가 있다.
우테대학교에는 소셜미디어가 등록되어 있지 않다.
+ */ + @BeforeEach + void setUp() { + School 테코대학교 = createSchool("테코대학교", "teco.ac.kr"); + School 우테대학교 = createSchool("우테대학교", "wote.ac.kr"); + + 테코대학교_축제 = festivalRepository.save(FestivalFixture.builder() + .startDate(now) + .endDate(now.plusDays(2)) + .school(테코대학교) + .build() + ); + 테코대학교_공연_없는_축제 = festivalRepository.save(FestivalFixture.builder() + .startDate(now) + .endDate(now) + .school(테코대학교) + .build() + ); + 우테대학교_축제 = festivalRepository.save(FestivalFixture.builder() + .startDate(now) + .endDate(now) + .school(우테대학교) + .build() + ); + Artist 아티스트A = createArtist("아티스트A"); + + Stage 테코대학교_축제_1일차_공연 = stageRepository.save(StageFixture.builder() + .festival(테코대학교_축제) + .startTime(now.atTime(18, 0)) + .build() + ); + Stage 테코대학교_축제_2일차_공연 = stageRepository.save(StageFixture.builder() + .festival(테코대학교_축제) + .startTime(now.plusDays(1).atTime(18, 0)) + .build() + ); + Stage 테코대학교_축제_3일차_공연 = stageRepository.save(StageFixture.builder() + .festival(테코대학교_축제) + .startTime(now.plusDays(2).atTime(18, 0)) + .build() + ); + Stage 우테대학교_축제_당일_공연 = stageRepository.save(StageFixture.builder() + .festival(우테대학교_축제) + .startTime(now.atTime(18, 0)) + .build() + ); + stageArtistRepository.save(StageArtistFixture.builder(테코대학교_축제_1일차_공연.getId(), 아티스트A.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(테코대학교_축제_2일차_공연.getId(), 아티스트A.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(테코대학교_축제_3일차_공연.getId(), 아티스트A.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(우테대학교_축제_당일_공연.getId(), 아티스트A.getId()).build()); + + socialMediaRepository.save(SocialMediaFixture.builder() + .ownerId(테코대학교.getId()) + .ownerType(OwnerType.SCHOOL) + .mediaType(SocialMediaType.INSTAGRAM) + .name("총학생회 인스타그램") + .logoUrl("https://logo.com/instagram.png") + .url("https://instagram.com/테코대학교_총학생회") + .build()); + socialMediaRepository.save(SocialMediaFixture.builder() + .ownerId(테코대학교.getId()) + .ownerType(OwnerType.SCHOOL) + .mediaType(SocialMediaType.FACEBOOK) + .name("총학생회 페이스북") + .logoUrl("https://logo.com/instagram.png") + .url("https://facebook.com/테코대학교_총학생회") + .build() + ); + } + + private Artist createArtist(String artistName) { + Artist artist = ArtistFixture.builder() + .name(artistName) + .build(); + return artistRepository.save(artist); + } + + private School createSchool(String schoolName, String domain) { + School school = SchoolFixture.builder() + .name(schoolName) + .domain(domain) + .build(); + return schoolRepository.save(school); + } + + @Test + void 축제의_식별자로_축제의_상세_조회를_할_수_있다() { + // when + var response = festivalDetailV1QueryService.findFestivalDetail(테코대학교_축제.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(response.id()).isEqualTo(테코대학교_축제.getId()); + softly.assertThat(response.startDate()).isEqualTo("2077-06-30"); + softly.assertThat(response.endDate()).isEqualTo("2077-07-02"); + softly.assertThat(response.school().name()).isEqualTo("테코대학교"); + softly.assertThat(response.socialMedias()) + .map(SocialMediaV1Response::name) + .containsExactlyInAnyOrder("총학생회 인스타그램", "총학생회 페이스북"); + softly.assertThat(response.stages()).hasSize(3); + }); + } + + @Test + void 공연_목록은_공연의_시작_시간_기준으로_정렬된다() { + // when + var response = festivalDetailV1QueryService.findFestivalDetail(테코대학교_축제.getId()); + + // then + assertThat(response.stages()) + .map(StageV1Response::startDateTime) + .map(LocalDateTime::toLocalDate) + .containsExactly(now, now.plusDays(1), now.plusDays(2)); + } + + @Test + void 축제에_공연이_없으면_응답의_공연에는_비어있는_컬렉션이_반환된다() { + // when + var response = festivalDetailV1QueryService.findFestivalDetail(테코대학교_공연_없는_축제.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(response.id()).isEqualTo(테코대학교_공연_없는_축제.getId()); + softly.assertThat(response.stages()).isEmpty(); + softly.assertThat(response.socialMedias()) + .map(SocialMediaV1Response::name) + .containsExactlyInAnyOrder("총학생회 인스타그램", "총학생회 페이스북"); + }); + } + + @Test + void 축제에_속한_학교에_소셜미디어가_없으면_소셜미디어에는_비어있는_컬렉션이_반환된다() { + // when + var response = festivalDetailV1QueryService.findFestivalDetail(우테대학교_축제.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(response.id()).isEqualTo(우테대학교_축제.getId()); + softly.assertThat(response.socialMedias()).isEmpty(); + softly.assertThat(response.stages()).hasSize(1); + }); + } + + @Test + void 축제의_식별자에_대한_축제가_없으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> festivalDetailV1QueryService.findFestivalDetail(4885L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.FESTIVAL_NOT_FOUND.getMessage()); + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/query/FestivalSearchV1QueryServiceTest.java b/backend/src/test/java/com/festago/festival/application/integration/query/FestivalSearchV1QueryServiceTest.java new file mode 100644 index 000000000..4645baa23 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/query/FestivalSearchV1QueryServiceTest.java @@ -0,0 +1,424 @@ +package com.festago.festival.application.integration.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.festival.application.FestivalSearchV1QueryService; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.FestivalSearchV1Response; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.FestivalQueryInfoFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageArtistFixture; +import com.festago.support.fixture.StageFixture; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalSearchV1QueryServiceTest extends ApplicationIntegrationTest { + + @Autowired + StageArtistRepository stageArtistRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + ArtistRepository artistRepository; + + @Autowired + FestivalSearchV1QueryService festivalSearchV1QueryService; + + School 부산_학교; + School 서울_학교; + School 대구_학교; + + Festival 부산대_종료_축제; + Festival 서울대_진행_축제; + Festival 대구대_예정_축제; + + Stage 부산대_종료_공연; + Stage 서울_진행_공연; + Stage 대구_예정_공연; + + LocalDate nowDate; + LocalDateTime nowDateTime; + + @BeforeEach + void setting() { + nowDate = LocalDate.now(); + nowDateTime = LocalDateTime.now(); + + 부산_학교 = schoolRepository.save(SchoolFixture.builder() + .domain("domain1") + .name("부산 학교") + .region(SchoolRegion.부산) + .build()); + 서울_학교 = schoolRepository.save(SchoolFixture.builder() + .domain("domain2") + .name("서울 학교") + .region(SchoolRegion.서울) + .build()); + 대구_학교 = schoolRepository.save(SchoolFixture.builder() + .domain("domain3") + .name("대구 학교") + .region(SchoolRegion.대구) + .build()); + + 부산대_종료_축제 = festivalRepository.save(FestivalFixture.builder() + .name("부산대학교 축제") + .startDate(nowDate.minusDays(5)) + .endDate(nowDate.minusDays(1)) + .school(부산_학교) + .build()); + 서울대_진행_축제 = festivalRepository.save(FestivalFixture.builder() + .name("서울대학교 축제") + .startDate(nowDate.minusDays(1)) + .endDate(nowDate.plusDays(3)) + .school(서울_학교) + .build()); + 대구대_예정_축제 = festivalRepository.save(FestivalFixture.builder() + .name("대구대학교 축제") + .startDate(nowDate.plusDays(1)) + .endDate(nowDate.plusDays(5)) + .school(대구_학교) + .build()); + + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(부산대_종료_축제.getId()).build()); + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(서울대_진행_축제.getId()).build()); + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(대구대_예정_축제.getId()).build()); + + 부산대_종료_공연 = stageRepository.save(StageFixture.builder() + .startTime(nowDateTime.minusDays(5L)) + .ticketOpenTime(nowDateTime.minusDays(6L)) + .festival(부산대_종료_축제) + .build()); + 서울_진행_공연 = stageRepository.save(StageFixture.builder() + .startTime(nowDateTime.minusDays(1L)) + .ticketOpenTime(nowDateTime.minusDays(2L)) + .festival(서울대_진행_축제) + .build()); + 대구_예정_공연 = stageRepository.save(StageFixture.builder() + .startTime(nowDateTime.plusDays(1L)) + .ticketOpenTime(nowDateTime) + .festival(대구대_예정_축제) + .build()); + } + + @Nested + class 학교_기반_축제_검색에서 { + + @Test + void 대_로끝나는_검색은_학교_검색으로_들어간다() { + // given + String keyword = "부산대"; + + // when + List actual = festivalSearchV1QueryService.search(keyword); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(1); + softly.assertThat(actual.get(0).name()).contains(keyword); + }); + } + + @Test + void 대학교_로_끝나는_검색은_학교_검색으로_들어간다() { + // given + String keyword = "부산대학교"; + + // when + List actual = festivalSearchV1QueryService.search(keyword); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(1); + softly.assertThat(actual.get(0).name()).contains(keyword); + }); + } + + @Test + void 학교_이름만_으로_검색_가능하다() { + // given + String keyword = "부산"; + + // when + List actual = festivalSearchV1QueryService.search(keyword); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(1); + softly.assertThat(actual.get(0).name()).contains(keyword); + }); + } + + @Test + void 검색은_진행_예정_종료로_정렬된다() { + // given + String keyword = "부산"; + + Festival 부산대학교_예정_축제 = festivalRepository.save(FestivalFixture.builder() + .name("부산대학교 예정 축제") + .startDate(nowDate.plusDays(1)) + .endDate(nowDate.plusDays(3)) + .school(부산_학교) + .build()); + + Festival 부산대학교_진행_축제 = festivalRepository.save(FestivalFixture.builder() + .name("부산대학교 진행 축제") + .startDate(nowDate.minusDays(5)) + .endDate(nowDate.plusDays(1)) + .school(부산_학교) + .build()); + + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(부산대학교_예정_축제.getId()).build()); + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(부산대학교_진행_축제.getId()).build()); + + // when + List actual = festivalSearchV1QueryService.search(keyword); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(3); + softly.assertThat(actual) + .map(festivalSearchV1Response -> festivalSearchV1Response.name()) + .containsExactly(부산대학교_진행_축제.getName(), 부산대학교_예정_축제.getName(), 부산대_종료_축제.getName()); + }); + } + } + + @Nested + class 아티스트_기반_축제_검색에서 { + + @Nested + class 두_글자_이상_라이크_검색은 { + + @Test + void 키워드가_두_글자_이상일_때_해당_키워드를_가진_아티스트의_정보를_반환한다() { + // given + Artist 오리 = artistRepository.save(ArtistFixture.builder() + .name("오리") + .build()); + Artist 우푸우 = artistRepository.save(ArtistFixture.builder() + .name("우푸우") + .build()); + Artist 글렌 = artistRepository.save(ArtistFixture.builder() + .name("글렌") + .build()); + + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 오리.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 우푸우.getId()).build()); + + stageArtistRepository.save(StageArtistFixture.builder(서울_진행_공연.getId(), 오리.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(서울_진행_공연.getId(), 글렌.getId()).build()); + + stageArtistRepository.save(StageArtistFixture.builder(대구_예정_공연.getId(), 우푸우.getId()).build()); + + // when + List actual = festivalSearchV1QueryService.search("푸우"); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual.get(0).name()).isEqualTo("대구대학교 축제"); + softly.assertThat(actual.get(1).name()).isEqualTo("부산대학교 축제"); + }); + } + + @Test + void 해당하는_키워드의_아티스트가_없으면_빈_리스트을_반환한다() { + // given + Artist 오리 = artistRepository.save(ArtistFixture.builder() + .name("오리") + .build()); + Artist 우푸우 = artistRepository.save(ArtistFixture.builder() + .name("우푸우") + .build()); + Artist 글렌 = artistRepository.save(ArtistFixture.builder() + .name("글렌") + .build()); + + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 오리.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 우푸우.getId()).build()); + + stageArtistRepository.save(StageArtistFixture.builder(서울_진행_공연.getId(), 오리.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(서울_진행_공연.getId(), 글렌.getId()).build()); + + stageArtistRepository.save(StageArtistFixture.builder(대구_예정_공연.getId(), 우푸우.getId()).build()); + + // when + List actual = festivalSearchV1QueryService.search("렌글"); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void 아티스트가_공연에_참여하고_있지_않으면_빈_리스트가_반환된다() { + // given + artistRepository.save(ArtistFixture.builder() + .name("우푸우") + .build()); + + // when + List actual = festivalSearchV1QueryService.search("우푸"); + + // then + assertThat(actual).isEmpty(); + } + + } + + @Nested + class 한_글자_동일_검색은 { + + @Test + void 두_글자_이상_아티스트와_함께_검색되지_않는다() { + // given + Artist 푸우 = artistRepository.save(ArtistFixture.builder() + .name("푸우") + .build()); + Artist 푸 = artistRepository.save(ArtistFixture.builder() + .name("푸") + .build()); + + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 푸우.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 푸.getId()).build()); + + stageArtistRepository.save(StageArtistFixture.builder(서울_진행_공연.getId(), 푸.getId()).build()); + + // when + List actual = festivalSearchV1QueryService.search("푸"); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(2); + softly.assertThat(actual.get(0).name()).isEqualTo("서울대학교 축제"); + softly.assertThat(actual.get(1).name()).isEqualTo("부산대학교 축제"); + }); + } + + @Test + void 해당하는_키워드의_아티스트가_없으면_빈_리스트를_반환한다() { + // given + Artist 푸우 = artistRepository.save(ArtistFixture.builder() + .name("푸우") + .build()); + Artist 푸푸푸푸 = artistRepository.save(ArtistFixture.builder() + .name("푸푸푸푸") + .build()); + Artist 글렌 = artistRepository.save(ArtistFixture.builder() + .name("글렌") + .build()); + + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 푸우.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 푸푸푸푸.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 글렌.getId()).build()); + + // when + List actual = festivalSearchV1QueryService.search("푸"); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void 아티스트가_공연에_참여하고_있지_않으면_빈_리스트를_반환한다() { + // given + artistRepository.save(ArtistFixture.builder() + .name("우푸우") + .build()); + + // when + List actual = festivalSearchV1QueryService.search("우푸"); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void 검색은_진행_예정_종료로_정렬된다() { + // given + String keyword = "푸"; + + Festival 부산대학교_예정_축제 = festivalRepository.save(FestivalFixture.builder() + .name("부산대학교 예정 축제") + .startDate(nowDate.plusDays(1)) + .endDate(nowDate.plusDays(3)) + .school(부산_학교) + .build()); + + Festival 부산대학교_진행_축제 = festivalRepository.save(FestivalFixture.builder() + .name("부산대학교 진행 축제") + .startDate(nowDate.minusDays(5)) + .endDate(nowDate.plusDays(1)) + .school(부산_학교) + .build()); + + Artist 푸 = artistRepository.save(ArtistFixture.builder() + .name("푸") + .build()); + + Stage 부산대_예정_공연 = stageRepository.save(StageFixture.builder() + .startTime(nowDateTime.plusDays(2L)) + .ticketOpenTime(nowDateTime.plusMinutes(1L)) + .festival(부산대학교_예정_축제) + .build()); + + Stage 부산대_진행_공연 = stageRepository.save(StageFixture.builder() + .startTime(nowDateTime.minusDays(4L)) + .ticketOpenTime(nowDateTime.minusDays(6L)) + .festival(부산대학교_진행_축제) + .build()); + + stageArtistRepository.save(StageArtistFixture.builder(부산대_종료_공연.getId(), 푸.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(부산대_예정_공연.getId(), 푸.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(부산대_진행_공연.getId(), 푸.getId()).build()); + + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(부산대학교_예정_축제.getId()).build()); + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(부산대학교_진행_축제.getId()).build()); + + // when + List actual = festivalSearchV1QueryService.search(keyword); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).hasSize(3); + softly.assertThat(actual) + .map(festivalSearchV1Response -> festivalSearchV1Response.name()) + .containsExactly(부산대학교_진행_축제.getName(), 부산대학교_예정_축제.getName(), 부산대_종료_축제.getName()); + }); + } + } + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/query/FestivalV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/festival/application/integration/query/FestivalV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..46b73d75b --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/query/FestivalV1QueryServiceIntegrationTest.java @@ -0,0 +1,389 @@ +package com.festago.festival.application.integration.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; + +import com.festago.festival.application.FestivalV1QueryService; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.FestivalV1QueryRequest; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.repository.FestivalFilter; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.FestivalQueryInfoFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + FestivalV1QueryService festivalV1QueryService; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + Clock clock; + + LocalDate now = LocalDate.parse("2077-07-10"); + + // 진행중 + Festival 서울대학교_8일_12일_축제; + Festival 서울대학교_6일_12일_축제; + Festival 대구대학교_9일_12일_축제; + Festival 부산대학교_6일_13일_축제; + Festival 부산대학교_6일_12일_축제; + + // 진행 예정 + Festival 대구대학교_13일_14일_축제; + Festival 대구대학교_12일_14일_축제; + Festival 부산대학교_12일_14일_축제; + + /** + * 현재 시간

2023년 7월 10일

+ *

+ * 식별자는 순서대로 오름차순

+ *

+ * 진행 중 축제 5개

서울대학교 8~12일

서울대학교 6~12일

대구대학교 9~12일

부산대학교 6~13일

부산대학교 6~12일

+ *

+ * 진행 예정 축제 3개

대구대학교 13~14일

대구대학교 12~14일

부산대학교 12~14일 + */ + @BeforeEach + void setting() { + School 서울대학교 = createSchool("서울대학교", SchoolRegion.서울); + School 부산대학교 = createSchool("부산대학교", SchoolRegion.부산); + School 대구대학교 = createSchool("대구대학교", SchoolRegion.대구); + + // 진행 중 + 서울대학교_8일_12일_축제 = createFestival("서울대학교_8일_12일_축제", now.minusDays(2), now.plusDays(2), 서울대학교); + 서울대학교_6일_12일_축제 = createFestival("서울대학교_6일_12일_축제", now.minusDays(4), now.plusDays(2), 서울대학교); + 대구대학교_9일_12일_축제 = createFestival("대구대학교_9일_12일_축제", now.minusDays(1), now.plusDays(2), 대구대학교); + 부산대학교_6일_13일_축제 = createFestival("부산대학교_6일_13일_축제", now.minusDays(4), now.plusDays(3), 부산대학교); + 부산대학교_6일_12일_축제 = createFestival("부산대학교_6일_12일_축제", now.minusDays(4), now.plusDays(2), 부산대학교); + + // 진행 예정 + 대구대학교_13일_14일_축제 = createFestival("대구대학교_13일_14일_축제", now.plusDays(3), now.plusDays(4), 대구대학교); + 부산대학교_12일_14일_축제 = createFestival("부산대학교_12일_14일_축제", now.plusDays(2), now.plusDays(4), 부산대학교); + 대구대학교_12일_14일_축제 = createFestival("대구대학교_12일_14일_축제", now.plusDays(2), now.plusDays(4), 대구대학교); + + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + } + + private School createSchool(String schoolName, SchoolRegion region) { + return schoolRepository.save(SchoolFixture.builder().name(schoolName).region(region).build()); + } + + private Festival createFestival(String festivalName, LocalDate startDate, LocalDate endDate, School school) { + Festival festival = festivalRepository.save(FestivalFixture.builder() + .name(festivalName) + .startDate(startDate) + .endDate(endDate) + .school(school) + .build() + ); + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(festival.getId()).build()); + return festival; + } + + @Nested + class 지역_필터_미적용 { + + @Test + void 진행_중_축제는_5개_이다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, null, null); + + // when + var actual = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertSoftly(softly -> { + softly.assertThat(actual.isLast()).isTrue(); + softly.assertThat(actual.getContent()).hasSize(5); + }); + } + + @Test + void 진행_예정_축제는_3개_이다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PLANNED, null, null); + + // when + var actual = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertSoftly(softly -> { + softly.assertThat(actual.isLast()).isTrue(); + softly.assertThat(actual.getContent()).hasSize(3); + }); + } + + @Test + void 원하는_갯수의_축제를_조회하면_마지막_페이지_여부를_알_수_있다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(4), request); + + // then + assertSoftly(softly -> { + softly.assertThat(response.isLast()).isFalse(); + softly.assertThat(response.getContent()).hasSize(4); + }); + } + + @Test + void 진행_예정_축제는_시작_날짜가_빠른_순으로_정렬되고_시작_날짜가_같으면_식별자의_오름차순으로_정렬되어_반환된다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PLANNED, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertThat(response.getContent()) + .map(FestivalV1Response::id) + .containsExactly( + 부산대학교_12일_14일_축제.getId(), + 대구대학교_12일_14일_축제.getId(), + 대구대학교_13일_14일_축제.getId() + ); + } + + @Test + void 진행_중_축제는_시작_날짜가_느린_순으로_정렬되고_시작_날짜가_같으면_식별자의_오름차순으로_정렬되어_반환된다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertThat(response.getContent()) + .map(FestivalV1Response::id) + .containsExactly( + 대구대학교_9일_12일_축제.getId(), + 서울대학교_8일_12일_축제.getId(), + 서울대학교_6일_12일_축제.getId(), + 부산대학교_6일_13일_축제.getId(), + 부산대학교_6일_12일_축제.getId() + ); + } + + @Test + void 커서_기반_페이징이_적용되어야_한다() { + // given + var firstRequest = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, null, null); + var firstResponse = festivalV1QueryService.findFestivals(Pageable.ofSize(2), firstRequest); + var lastElement = firstResponse.getContent().get(1); + var secondRequest = new FestivalV1QueryRequest(SchoolRegion.ANY, FestivalFilter.PROGRESS, lastElement.id(), + lastElement.startDate()); + + // when + var secondResponse = festivalV1QueryService.findFestivals(Pageable.ofSize(5), secondRequest); + + // then + assertSoftly(softly -> { + softly.assertThat(firstResponse.hasNext()).isTrue(); + softly.assertThat(firstResponse.getSize()).isEqualTo(2); + softly.assertThat(secondResponse.hasNext()).isFalse(); + softly.assertThat(secondResponse.getContent()) + .map(FestivalV1Response::id) + .containsExactly( + 서울대학교_6일_12일_축제.getId(), + 부산대학교_6일_13일_축제.getId(), + 부산대학교_6일_12일_축제.getId() + ); + }); + } + } + + @Nested + class 지역_필터_적용 { + + @Test + void 지역이_서울인_진행_중_축제는_2개_이다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.서울, FestivalFilter.PROGRESS, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertSoftly(softly -> { + softly.assertThat(response.isLast()).isTrue(); + softly.assertThat(response.getContent()).hasSize(2); + }); + } + + @Test + void 지역이_대구인_진행_중_축제는_1개_이다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.대구, FestivalFilter.PROGRESS, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertSoftly(softly -> { + softly.assertThat(response.isLast()).isTrue(); + softly.assertThat(response.getContent()).hasSize(1); + }); + } + + @Test + void 지역이_부산인_진행_중_축제는_2개_이다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.부산, FestivalFilter.PROGRESS, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertSoftly(softly -> { + softly.assertThat(response.isLast()).isTrue(); + softly.assertThat(response.getContent()).hasSize(2); + }); + } + + @Test + void 지역이_서울인_진행_예정_축제는_0개_이다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.서울, FestivalFilter.PLANNED, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertSoftly(softly -> { + softly.assertThat(response.isLast()).isTrue(); + softly.assertThat(response.getContent()).isEmpty(); + }); + } + + @Test + void 지역이_부산인_진행_예정_축제는_1개_이다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.부산, FestivalFilter.PLANNED, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertSoftly(softly -> { + softly.assertThat(response.isLast()).isTrue(); + softly.assertThat(response.getContent()).hasSize(1); + }); + } + + @Test + void 지역이_대구인_진행_예정_축제는_2개_이다() { + // given + var request = new FestivalV1QueryRequest(SchoolRegion.대구, FestivalFilter.PLANNED, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertSoftly(softly -> { + softly.assertThat(response.isLast()).isTrue(); + softly.assertThat(response.getContent()).hasSize(2); + }); + } + + @ParameterizedTest + @MethodSource("지역별_진행_예정_축제_이름") + void 진행_예정_축제는_시작_날짜가_빠른_순으로_정렬되고_시작_날짜가_같으면_식별자의_오름차순으로_정렬되어_반환된다( + SchoolRegion region, + List festivalNames + ) { + // given + var request = new FestivalV1QueryRequest(region, FestivalFilter.PLANNED, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertThat(response.getContent()) + .map(FestivalV1Response::name) + .containsExactlyElementsOf(festivalNames); + } + + private static Stream 지역별_진행_예정_축제_이름() { + return Stream.of( + Arguments.of(SchoolRegion.서울, List.of()), + Arguments.of(SchoolRegion.대구, List.of( + "대구대학교_12일_14일_축제", + "대구대학교_13일_14일_축제" + )), + Arguments.of(SchoolRegion.부산, List.of( + "부산대학교_12일_14일_축제" + )) + ); + } + + @ParameterizedTest + @MethodSource("지역별_진행_중_축제_이름") + void 진행_중_축제는_시작_날짜가_느린_순으로_정렬되고_시작_날짜가_같으면_식별자의_오름차순으로_정렬되어_반환된다( + SchoolRegion region, + List festivalNames + ) { + // given + var request = new FestivalV1QueryRequest(region, FestivalFilter.PROGRESS, null, null); + + // when + var response = festivalV1QueryService.findFestivals(Pageable.ofSize(10), request); + + // then + assertThat(response.getContent()) + .map(FestivalV1Response::name) + .containsExactlyElementsOf(festivalNames); + } + + private static Stream 지역별_진행_중_축제_이름() { + return Stream.of( + Arguments.of(SchoolRegion.서울, List.of( + "서울대학교_8일_12일_축제", + "서울대학교_6일_12일_축제" + )), + Arguments.of(SchoolRegion.대구, List.of( + "대구대학교_9일_12일_축제" + )), + Arguments.of(SchoolRegion.부산, List.of( + "부산대학교_6일_13일_축제", + "부산대학교_6일_12일_축제" + )) + ); + } + } +} diff --git a/backend/src/test/java/com/festago/festival/application/integration/query/PopularFestivalV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/festival/application/integration/query/PopularFestivalV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..77f5526dc --- /dev/null +++ b/backend/src/test/java/com/festago/festival/application/integration/query/PopularFestivalV1QueryServiceIntegrationTest.java @@ -0,0 +1,136 @@ +package com.festago.festival.application.integration.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.festival.application.PopularFestivalV1QueryService; +import com.festago.festival.domain.Festival; +import com.festago.festival.dto.FestivalV1Response; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.FestivalQueryInfoFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PopularFestivalV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + PopularFestivalV1QueryService popularQueryService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + School 대학교; + + Festival 첫번째로_저장된_축제; + Festival 두번째로_저장된_축제; + Festival 세번째로_저장된_축제; + Festival 네번째로_저장된_축제; + Festival 다섯번째로_저장된_축제; + Festival 여섯번째로_저장된_축제; + Festival 일곱번째로_저장된_축제; + Festival 여덟번째로_저장된_축제; + Festival 아홉번째로_저장된_공연없는_축제; + Festival 열번째로_저장된_공연없는_축제; + Festival 열한번쨰로_저장된_기간이_지난_축제; + + @BeforeEach + void setUp() { + 대학교 = schoolRepository.save(SchoolFixture.builder().build()); + + LocalDate startDate = LocalDate.now();; + LocalDate endDate = startDate.plusDays(3); + + 첫번째로_저장된_축제 = createFestivalWithFilledFestivalInfo(startDate, endDate); + 두번째로_저장된_축제 = createFestivalWithFilledFestivalInfo(startDate, endDate); + 세번째로_저장된_축제 = createFestivalWithFilledFestivalInfo(startDate, endDate); + 네번째로_저장된_축제 = createFestivalWithFilledFestivalInfo(startDate, endDate); + 다섯번째로_저장된_축제 = createFestivalWithFilledFestivalInfo(startDate, endDate); + 여섯번째로_저장된_축제 = createFestivalWithFilledFestivalInfo(startDate, endDate); + 일곱번째로_저장된_축제 = createFestivalWithFilledFestivalInfo(startDate, endDate); + 여덟번째로_저장된_축제 = createFestivalWithFilledFestivalInfo(startDate, endDate); + 아홉번째로_저장된_공연없는_축제 = createFestivalWithEmptyFestivalInfo(startDate, endDate); + 열번째로_저장된_공연없는_축제 = createFestivalWithEmptyFestivalInfo(startDate, endDate); + 열한번쨰로_저장된_기간이_지난_축제 = createFestivalWithFilledFestivalInfo(startDate.minusDays(10L), endDate.minusDays(13L)); + } + + private Festival createFestivalWithFilledFestivalInfo(LocalDate startDate, LocalDate endDate) { + Festival festival = festivalRepository.save(makeBaseFestivalFixture(startDate, endDate)); + festivalInfoRepository.save(makeBaseFestivalInfoFixture(festival) + .artistInfo(""" + { + notEmpty + } + """) + .build()); + return festival; + } + + private Festival makeBaseFestivalFixture(LocalDate startDate, LocalDate endDate) { + return FestivalFixture.builder() + .school(대학교) + .startDate(startDate) + .endDate(endDate) + .build(); + } + + private FestivalQueryInfoFixture makeBaseFestivalInfoFixture(Festival festival) { + return FestivalQueryInfoFixture.builder() + .festivalId(festival.getId()); + } + + private Festival createFestivalWithEmptyFestivalInfo(LocalDate startDate, LocalDate endDate) { + Festival festival = festivalRepository.save(makeBaseFestivalFixture(startDate, endDate)); + festivalInfoRepository.save(makeBaseFestivalInfoFixture(festival) + .build()); + return festival; + } + + @Test + void 인기_축제는_공연이등록된_축제중_7개까지_반환되고_식별자의_내림차순으로_정렬되어_조회된다() { + // when + var expect = popularQueryService.findPopularFestivals().content(); + + // then + assertThat(expect) + .map(FestivalV1Response::id) + .containsExactly( + 여덟번째로_저장된_축제.getId(), + 일곱번째로_저장된_축제.getId(), + 여섯번째로_저장된_축제.getId(), + 다섯번째로_저장된_축제.getId(), + 네번째로_저장된_축제.getId(), + 세번째로_저장된_축제.getId(), + 두번째로_저장된_축제.getId() + ); + } + + @Test + void 축제_기간이_끝난_축제는_반환되지_않는다() { + // when + var expect = popularQueryService.findPopularFestivals().content(); + + // then + assertThat(expect) + .map(FestivalV1Response::id) + .doesNotContain( + 열한번쨰로_저장된_기간이_지난_축제.getId() + ); + } +} diff --git a/backend/src/test/java/com/festago/festival/domain/FestivalDurationTest.java b/backend/src/test/java/com/festago/festival/domain/FestivalDurationTest.java new file mode 100644 index 000000000..2e9c9d62c --- /dev/null +++ b/backend/src/test/java/com/festago/festival/domain/FestivalDurationTest.java @@ -0,0 +1,108 @@ +package com.festago.festival.domain; + +import static com.festago.common.exception.ErrorCode.INVALID_FESTIVAL_DURATION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ValidException; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalDurationTest { + + LocalDate _6월_15일 = LocalDate.parse("2077-06-15"); + LocalDate _6월_16일 = LocalDate.parse("2077-06-16"); + LocalDate _6월_17일 = LocalDate.parse("2077-06-17"); + + @ParameterizedTest + @CsvSource({"2077-06-15,", ",2077-06-15"}) + void 시작일_또는_종료일이_null이면_예외(LocalDate startDate, LocalDate endDate) { + // when & then + assertThatThrownBy(() -> new FestivalDuration(startDate, endDate)) + .isInstanceOf(ValidException.class); + } + + @Test + void 시작일이_종료일_이전이면_예외() { + // when & then + assertThatThrownBy(() -> new FestivalDuration(_6월_17일, _6월_16일)) + .isInstanceOf(BadRequestException.class) + .hasMessage(INVALID_FESTIVAL_DURATION.getMessage()); + } + + @Nested + class isBeforeStartDate { + + @Test + void 기간의_시작일이_주어진_날짜보다_이후이면_거짓() { + // given + FestivalDuration _6월_16일_6월_16일 = new FestivalDuration(_6월_16일, _6월_16일); + + // then + boolean actual = _6월_16일_6월_16일.isStartDateBeforeTo(_6월_15일); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 시작일이_주어진_날짜에_포함되면_거짓() { + // given + FestivalDuration _6월_16일_6월_16일 = new FestivalDuration(_6월_16일, _6월_16일); + + // then + boolean actual = _6월_16일_6월_16일.isStartDateBeforeTo(_6월_16일); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 시작일이_주어진_날짜_이전이면_참() { + // given + FestivalDuration _6월_16일_6월_16일 = new FestivalDuration(_6월_16일, _6월_16일); + + // then + boolean actual = _6월_16일_6월_16일.isStartDateBeforeTo(_6월_17일); + + // then + assertThat(actual).isTrue(); + } + } + + @Nested + class isNotInDuration { + + @Test + void 기간에_포함되면_거짓() { + // given + FestivalDuration _6월_15일_6월_17일 = new FestivalDuration(_6월_15일, _6월_17일); + + // when + boolean actual = _6월_15일_6월_17일.isNotInDuration(_6월_16일); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 기간에_포함되지_않으면_참() { + // given + FestivalDuration _6월_16일_6월_17일 = new FestivalDuration(_6월_16일, _6월_17일); + + // when + boolean actual = _6월_16일_6월_17일.isNotInDuration(_6월_15일); + + // then + assertThat(actual).isTrue(); + } + } +} diff --git a/backend/src/test/java/com/festago/festival/domain/FestivalFilterTest.java b/backend/src/test/java/com/festago/festival/domain/FestivalFilterTest.java new file mode 100644 index 000000000..5a7957175 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/domain/FestivalFilterTest.java @@ -0,0 +1,45 @@ +package com.festago.festival.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.BadRequestException; +import com.festago.festival.repository.FestivalFilter; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalFilterTest { + + @Test + void 유효하지_않은_name_이면_예외() { + // given && when && then + assertThatThrownBy(() -> FestivalFilter.from("unvalid")) + .isInstanceOf(BadRequestException.class); + } + + @ValueSource(strings = {"progress", "Progress", "PROGRESS"}) + @ParameterizedTest + void PROGRESS_반환(String value) { + // given && when + FestivalFilter filter = FestivalFilter.from(value); + + // then + assertThat(filter).isEqualTo(FestivalFilter.PROGRESS); + } + + @ValueSource(strings = {"planned", "Planned", "PLANNED"}) + @ParameterizedTest + void PLANNED_반환(String value) { + // given && when + FestivalFilter filter = FestivalFilter.from(value); + + // then + assertThat(filter).isEqualTo(FestivalFilter.PLANNED); + } + +} diff --git a/backend/src/test/java/com/festago/festival/presentation/v1/FestivalV1ControllerTest.java b/backend/src/test/java/com/festago/festival/presentation/v1/FestivalV1ControllerTest.java new file mode 100644 index 000000000..7b50c2799 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/presentation/v1/FestivalV1ControllerTest.java @@ -0,0 +1,165 @@ +package com.festago.festival.presentation.v1; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.festival.application.FestivalDetailV1QueryService; +import com.festago.festival.dto.FestivalDetailV1Response; +import com.festago.festival.dto.SchoolV1Response; +import com.festago.festival.dto.SocialMediaV1Response; +import com.festago.festival.dto.StageV1Response; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.support.CustomWebMvcTest; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalV1ControllerTest { + + private static final String LAST_FESTIVAL_ID_KEY = "lastFestivalId"; + private static final String LAST_START_DATE_KEY = "lastStartDate"; + private static final String SIZE_KEY = "size"; + + @Autowired + MockMvc mockMvc; + + @Autowired + FestivalDetailV1QueryService festivalDetailV1QueryService; + + @Autowired + ObjectMapper objectMapper; + + @Nested + class 축제_목록_커서_기반_페이징_조회 { + + final String uri = "/api/v1/festivals"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 쿼리_파라미터에_festivalId와_lastStartDate를_모두_보내면_200_응답이_반환된다() throws Exception { + mockMvc.perform(get(uri) + .param(LAST_FESTIVAL_ID_KEY, "1") + .param(LAST_START_DATE_KEY, "1999-10-01")) + .andExpect(status().isOk()); + } + + @Test + void 쿼리_파라미터에_festivalId와_lastStartDate를_보내지_않아도_200_응답이_반환된다() throws Exception { + mockMvc.perform(get(uri)) + .andExpect(status().isOk()); + } + + @CsvSource(value = {"1,''", "'',2077-06-30"}) + @ParameterizedTest + void 쿼리_파라미터에_festivalId_또는_lastStartDate_중_하나만_보내면_400_응답이_반환된다( + String festivalId, String lastStartDate + ) throws Exception { + mockMvc.perform(get(uri) + .param(LAST_FESTIVAL_ID_KEY, festivalId) + .param(LAST_START_DATE_KEY, lastStartDate)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("festivalId, lastStartDate 두 값 모두 요청하거나 요청하지 않아야합니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"21"}) + void 쿼리_파라미터에_size가_20을_초과하면_400_응답이_반환된다(String size) throws Exception { + mockMvc.perform(get(uri) + .param(SIZE_KEY, size)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("최대 size 값을 초과했습니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"1", "20"}) + void 쿼리_파라미터에_size가_1_에서_20_사이면_200_응답이_반환된다(String size) throws Exception { + // given && when && then + mockMvc.perform(get(uri) + .param(SIZE_KEY, size)) + .andExpect(status().isOk()); + } + } + } + + @Nested + class 축제_상세_조회 { + + final String uri = "/api/v1/festivals/{festivalId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_축제_상세_정보가_반환된다() throws Exception { + // given + var expect = new FestivalDetailV1Response( + 1L, + "테코대학교 축제", + new SchoolV1Response( + 1L, + "테코대학교" + ), + LocalDate.parse("2077-06-30"), + LocalDate.parse("2077-06-30"), + "https://image.com/schoolImage.png", + Set.of( + new SocialMediaV1Response( + SocialMediaType.INSTAGRAM, + "총학 인스타", + "https://example.com/instagram.png", + "https://www.instagram.com/example_university" + ) + ), + Set.of( + new StageV1Response( + 1L, + LocalDateTime.parse("2077-06-30T00:00:00"), + null // @JsonRawValue 때문에 직렬화된 JSON을 다시 역직렬화 할 때 문제가 발생함 + ) + ) + ); + given(festivalDetailV1QueryService.findFestivalDetail(anyLong())) + .willReturn(expect); + // when & then + String content = mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + + // 이렇게 검증해도 괜찮은지 의문. 마치 거울보고 가위바위보를 하는 느낌이 강하게 듦 + // jsonPath를 사용하여 json 명세가 정확한지(오타, 누락) 명시적으로 검사가 필요할까?? + // 많은 andExpect(jsonPath("$.id").value(1L)) 절이 호출되어 보기에 불편하지만 + // 코드 리뷰시 DTO 내부를 헤집을 필요 없이, 테스트 코드만 보고 JSON 명세를 확인 가능한게 장점인듯 + // 또한, @JsonRawValue를 사용하여 역직렬화된 JSON을 다시 직렬화하는 것이 불가능함. + var actual = objectMapper.readValue(content, FestivalDetailV1Response.class); + assertThat(actual).isEqualTo(expect); + } + } + } +} diff --git a/backend/src/test/java/com/festago/festival/repository/MemoryFestivalQueryInfoRepository.java b/backend/src/test/java/com/festago/festival/repository/MemoryFestivalQueryInfoRepository.java new file mode 100644 index 000000000..a9f754815 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/repository/MemoryFestivalQueryInfoRepository.java @@ -0,0 +1,21 @@ +package com.festago.festival.repository; + +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.support.AbstractMemoryRepository; +import java.util.Objects; +import java.util.Optional; + +public class MemoryFestivalQueryInfoRepository extends AbstractMemoryRepository implements FestivalInfoRepository { + + @Override + public Optional findByFestivalId(Long festivalId) { + return memory.values().stream() + .filter(it -> Objects.equals(it.getFestivalId(), festivalId)) + .findAny(); + } + + @Override + public void deleteByFestivalId(Long festivalId) { + memory.entrySet().removeIf(it -> Objects.equals(it.getValue().getFestivalId(), festivalId)); + } +} diff --git a/backend/src/test/java/com/festago/festival/repository/MemoryFestivalRepository.java b/backend/src/test/java/com/festago/festival/repository/MemoryFestivalRepository.java new file mode 100644 index 000000000..9fd2ab317 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/repository/MemoryFestivalRepository.java @@ -0,0 +1,14 @@ +package com.festago.festival.repository; + +import com.festago.festival.domain.Festival; +import com.festago.support.AbstractMemoryRepository; +import java.util.Objects; + +public class MemoryFestivalRepository extends AbstractMemoryRepository implements FestivalRepository { + + @Override + public boolean existsBySchoolId(Long schoolId) { + return memory.values().stream() + .anyMatch(festival -> Objects.equals(festival.getSchool().getId(), schoolId)); + } +} diff --git a/backend/src/test/java/com/festago/infrastructure/JwtEntryCodeProviderTest.java b/backend/src/test/java/com/festago/infrastructure/JwtEntryCodeProviderTest.java deleted file mode 100644 index f1509b032..000000000 --- a/backend/src/test/java/com/festago/infrastructure/JwtEntryCodeProviderTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.festago.infrastructure; - -import static com.festago.common.exception.ErrorCode.INVALID_ENTRY_CODE_EXPIRATION_TIME; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.SoftAssertions.assertSoftly; - -import com.festago.common.exception.InternalServerException; -import com.festago.entry.application.EntryCodeProvider; -import com.festago.entry.domain.EntryCodePayload; -import com.festago.entry.infrastructure.JwtEntryCodeProvider; -import com.festago.support.MemberTicketFixture; -import com.festago.ticketing.domain.MemberTicket; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import java.util.Date; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; - -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class JwtEntryCodeProviderTest { - - private static final String SECRET_KEY = "1231231231231231223131231231231231231212312312"; - - EntryCodeProvider entryCodeProvider = new JwtEntryCodeProvider(SECRET_KEY); - - @Test - void expiredAt이_과거이면_예외() { - // given - Date now = new Date(); - Date expiredAt = new Date(now.getTime() - 1_000); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .id(1L) - .build(); - EntryCodePayload entryCodePayload = EntryCodePayload.from(memberTicket); - - // when & then - assertThatThrownBy(() -> entryCodeProvider.provide(entryCodePayload, expiredAt)) - .isInstanceOf(InternalServerException.class) - .hasMessage(INVALID_ENTRY_CODE_EXPIRATION_TIME.getMessage()); - } - - @Test - void JWT_토큰을_생성() { - // given - long period = 30_000; - MemberTicket memberTicket = MemberTicketFixture.memberTicket().id(1L).build(); - Date now = new Date(); - Date expiredAt = new Date(now.getTime() + period); - EntryCodePayload entryCodePayload = EntryCodePayload.from(memberTicket); - - // when - String code = entryCodeProvider.provide(entryCodePayload, expiredAt); - - // then - Claims claims = Jwts.parserBuilder() - .setSigningKey(SECRET_KEY.getBytes()) - .build() - .parseClaimsJws(code) - .getBody(); - - Long actualMemberTicketId = claims.get("ticketId", Long.class); - Date actualExpiredAt = claims.getExpiration(); - - assertSoftly(softly -> { - softly.assertThat(actualExpiredAt).isEqualToIgnoringMillis(expiredAt); - softly.assertThat(actualMemberTicketId).isEqualTo(memberTicket.getId()); - }); - } -} diff --git a/backend/src/test/java/com/festago/member/domain/MemberTest.java b/backend/src/test/java/com/festago/member/domain/MemberTest.java new file mode 100644 index 000000000..8c55e45a9 --- /dev/null +++ b/backend/src/test/java/com/festago/member/domain/MemberTest.java @@ -0,0 +1,94 @@ +package com.festago.member.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.auth.domain.SocialType; +import com.festago.common.exception.ValidException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberTest { + + @Test + void Member_생성_성공() { + // given + Member member = new Member(1L, "12345", SocialType.FESTAGO, "nickname", "profileImage.png"); + + // when & then + assertThat(member.getId()).isEqualTo(1L); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void socialId가_null_또는_공백이면_예외(String socialId) { + // when & then + assertThatThrownBy(() -> new Member(1L, socialId, SocialType.FESTAGO, "nickname", "profileImage.png")) + .isInstanceOf(ValidException.class); + } + + @Test + void socialId의_길이가_255자를_초과하면_예외() { + // given + String socialId = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> new Member(1L, socialId, SocialType.FESTAGO, "nickname", "profileImage.png")) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + void socialType이_null이면_예외(SocialType socialType) { + // when & then + assertThatThrownBy(() -> new Member(1L, "12345", socialType, "nickname", "profileImage.png")) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void nickname이_null_또는_공백이면_예외(String nickname) { + // when & then + assertThatThrownBy(() -> new Member(1L, "12345", SocialType.FESTAGO, nickname, "profileImage.png")) + .isInstanceOf(ValidException.class); + } + + @Test + void nickname의_길이가_30자를_초과하면_예외() { + // given + String nickname = "1".repeat(31); + + // when & then + assertThatThrownBy(() -> new Member(1L, "12345", SocialType.FESTAGO, nickname, "profileImage.png")) + .isInstanceOf(ValidException.class); + } + + @Test + void profileImage의_길이가_255자를_초과하면_예외() { + // given + String profileImage = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> new Member(1L, "12345", SocialType.FESTAGO, "nickname", profileImage)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void profileImage가_null_또는_공백이면_기본값이_할당된다(String profileImage) { + // given + Member actual = new Member("12345", SocialType.FESTAGO, "nickname", profileImage); + + // when & then + assertThat(actual.getProfileImage()).isEmpty(); + } +} diff --git a/backend/src/test/java/com/festago/member/infrastructure/DefaultNicknamePolicyImplTest.java b/backend/src/test/java/com/festago/member/infrastructure/DefaultNicknamePolicyImplTest.java new file mode 100644 index 000000000..166f7710b --- /dev/null +++ b/backend/src/test/java/com/festago/member/infrastructure/DefaultNicknamePolicyImplTest.java @@ -0,0 +1,29 @@ +package com.festago.member.infrastructure; + +import static org.assertj.core.api.Assertions.*; + +import com.festago.member.domain.DefaultNicknamePolicy; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class DefaultNicknamePolicyImplTest { + + @Test + void 형용사와_명사가_합쳐진_닉네임이_반환된다() { + // given + DefaultNicknamePolicy defaultNicknamePolicy = new DefaultNicknamePolicyImpl( + List.of("춤추는"), + List.of("다람쥐") + ); + + // when + String nickname = defaultNicknamePolicy.generate(); + + // then + assertThat(nickname).isEqualTo("춤추는 다람쥐"); + } +} diff --git a/backend/src/test/java/com/festago/domain/MemberRepositoryTest.java b/backend/src/test/java/com/festago/member/repository/MemberRepositoryTest.java similarity index 84% rename from backend/src/test/java/com/festago/domain/MemberRepositoryTest.java rename to backend/src/test/java/com/festago/member/repository/MemberRepositoryTest.java index 6e6e2e927..43b4d0d7d 100644 --- a/backend/src/test/java/com/festago/domain/MemberRepositoryTest.java +++ b/backend/src/test/java/com/festago/member/repository/MemberRepositoryTest.java @@ -1,21 +1,20 @@ -package com.festago.domain; +package com.festago.member.repository; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.festago.member.domain.Member; -import com.festago.member.repository.MemberRepository; -import com.festago.support.MemberFixture; +import com.festago.support.RepositoryTest; +import com.festago.support.fixture.MemberFixture; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") -@DataJpaTest +@RepositoryTest class MemberRepositoryTest { @Autowired @@ -27,7 +26,7 @@ class MemberRepositoryTest { @Test void 소셜_아이디와_소셜_타입으로_멤버를_찾는다() { // given - Member member = MemberFixture.member() + Member member = MemberFixture.builder() .build(); Member expected = memberRepository.save(member); @@ -42,7 +41,7 @@ class MemberRepositoryTest { @Test void 회원_삭제() { // given - Member member = MemberFixture.member() + Member member = MemberFixture.builder() .build(); Member expected = memberRepository.save(member); diff --git a/backend/src/test/java/com/festago/member/repository/MemoryMemberRepository.java b/backend/src/test/java/com/festago/member/repository/MemoryMemberRepository.java new file mode 100644 index 000000000..390854743 --- /dev/null +++ b/backend/src/test/java/com/festago/member/repository/MemoryMemberRepository.java @@ -0,0 +1,23 @@ +package com.festago.member.repository; + +import com.festago.auth.domain.SocialType; +import com.festago.member.domain.Member; +import com.festago.support.AbstractMemoryRepository; +import java.util.Objects; +import java.util.Optional; + +public class MemoryMemberRepository extends AbstractMemoryRepository implements MemberRepository { + + @Override + public void delete(Member member) { + memory.remove(member.getId()); + } + + @Override + public Optional findBySocialIdAndSocialType(String socialId, SocialType socialType) { + return memory.values().stream() + .filter(member -> Objects.equals(member.getSocialId(), socialId)) + .filter(member -> Objects.equals(member.getSocialType(), socialType)) + .findAny(); + } +} diff --git a/backend/src/test/java/com/festago/mock/application/integration/MockDataServiceIntegrationTest.java b/backend/src/test/java/com/festago/mock/application/integration/MockDataServiceIntegrationTest.java new file mode 100644 index 000000000..27a3776c9 --- /dev/null +++ b/backend/src/test/java/com/festago/mock/application/integration/MockDataServiceIntegrationTest.java @@ -0,0 +1,51 @@ +package com.festago.mock.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.mock.application.MockDataService; +import com.festago.support.ApplicationIntegrationTest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MockDataServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + MockDataService mockDataService; + + @Autowired + EntityManager em; + + @Nested + class makeMockFestivals { + + @BeforeEach + void setUp() { + mockDataService.makeMockSchools(); + mockDataService.makeMockArtists(); + } + + @Test + void 쿼리_최적화_정보가_생성되어야_한다() { + // when + mockDataService.makeMockFestivals(); + + // then + Long stageQueryInfoCount = em.createQuery("select count(*) from StageQueryInfo sqi", Long.class) + .getSingleResult(); + Long festivalQueryInfoCount = em.createQuery("select count(*) from FestivalQueryInfo fqi", Long.class) + .getSingleResult(); + assertSoftly(softly -> { + assertThat(stageQueryInfoCount).isNotZero(); + assertThat(festivalQueryInfoCount).isNotZero(); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/mock/domain/MockArtistsGeneratorTest.java b/backend/src/test/java/com/festago/mock/domain/MockArtistsGeneratorTest.java new file mode 100644 index 000000000..65809f1d8 --- /dev/null +++ b/backend/src/test/java/com/festago/mock/domain/MockArtistsGeneratorTest.java @@ -0,0 +1,31 @@ +package com.festago.mock.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.artist.domain.Artist; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MockArtistsGeneratorTest { + + MockArtistsGenerator mockArtistsGenerator = new MockArtistsGenerator(); + + @Test + void MockArtist_목록으로_아티스트를_생성한다() { + // when + List actual = mockArtistsGenerator.generate(); + + // then + List expect = Arrays.stream(MockArtist.values()) + .map(Enum::name) + .toList(); + assertThat(actual) + .map(Artist::getName) + .containsExactlyInAnyOrderElementsOf(expect); + } +} diff --git a/backend/src/test/java/com/festago/mock/domain/MockFestivalsGeneratorTest.java b/backend/src/test/java/com/festago/mock/domain/MockFestivalsGeneratorTest.java new file mode 100644 index 000000000..cc986c3cc --- /dev/null +++ b/backend/src/test/java/com/festago/mock/domain/MockFestivalsGeneratorTest.java @@ -0,0 +1,56 @@ +package com.festago.mock.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.spy; + +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalDuration; +import com.festago.school.domain.School; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.SchoolFixture; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MockFestivalsGeneratorTest { + + Clock clock; + MockFestivalDurationGenerator mockFestivalDurationGenerator; + MockFestivalsGenerator mockFestivalsGenerator; + + @BeforeEach + void setUp() { + clock = spy(Clock.systemDefaultZone()); + mockFestivalDurationGenerator = mock(MockFestivalDurationGenerator.class); + mockFestivalsGenerator = new MockFestivalsGenerator(clock, mockFestivalDurationGenerator); + } + + @Test + void 인자로_들어온_학교의_개수만큼_축제를_생성한다() { + // given + LocalDate now = LocalDate.parse("2077-06-30"); + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + given(mockFestivalDurationGenerator.generateFestivalDuration(any(LocalDate.class))) + .willReturn(new FestivalDuration(now, now)); + List schools = IntStream.rangeClosed(1, 10) + .mapToObj(i -> SchoolFixture.builder().build()) + .toList(); + + // when + List actual = mockFestivalsGenerator.generate(schools); + + // then + assertThat(actual).hasSize(schools.size()); + } +} diff --git a/backend/src/test/java/com/festago/mock/domain/MockSchoolsGeneratorTest.java b/backend/src/test/java/com/festago/mock/domain/MockSchoolsGeneratorTest.java new file mode 100644 index 000000000..f9586e191 --- /dev/null +++ b/backend/src/test/java/com/festago/mock/domain/MockSchoolsGeneratorTest.java @@ -0,0 +1,32 @@ +package com.festago.mock.domain; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MockSchoolsGeneratorTest { + + MockSchoolsGenerator mockSchoolsGenerator = new MockSchoolsGenerator(); + + @Test + void SchoolRegion_ANY를_제외한_지역_당_3개의_학교를_생성한다() { + // when + List actual = mockSchoolsGenerator.generate(); + + // then + int expectSize = (SchoolRegion.values().length - 1) * 3; + assertSoftly(softly -> { + softly.assertThat(actual) + .map(School::getRegion) + .doesNotContain(SchoolRegion.ANY); + softly.assertThat(actual).hasSize(expectSize); + }); + } +} diff --git a/backend/src/test/java/com/festago/mock/domain/MockStageArtistsGeneratorTest.java b/backend/src/test/java/com/festago/mock/domain/MockStageArtistsGeneratorTest.java new file mode 100644 index 000000000..9f4059dad --- /dev/null +++ b/backend/src/test/java/com/festago/mock/domain/MockStageArtistsGeneratorTest.java @@ -0,0 +1,111 @@ +package com.festago.mock.domain; + +import static java.util.stream.Collectors.counting; +import static java.util.stream.Collectors.groupingBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.artist.domain.Artist; +import com.festago.common.exception.UnexpectedException; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.StageArtist; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.StageFixture; +import java.util.List; +import java.util.Map; +import java.util.stream.LongStream; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MockStageArtistsGeneratorTest { + + MockStageArtistsGenerator mockStageArtistsGenerator = new MockStageArtistsGenerator(); + + @Test + void 각_공연에_stagePerArtist_만큼_StageArtist를_생성한다() { + // given + int stageCount = 3; + int artistCount = 12; + List stages = createStages(stageCount); + List artists = createArtists(artistCount); + + // when + List actual = mockStageArtistsGenerator.generate(stages, artists); + + // then + Map stageIdToArtistCount = actual.stream() + .collect(groupingBy(StageArtist::getStageId, counting())); + assertThat(stageIdToArtistCount.values()) + .containsOnly(3L); + } + + @Test + void 생성된_StageArtist에는_중복된_Artist가_존재하지_않는다() { + // given + int stageCount = 3; + int artistCount = 9; + List stages = createStages(stageCount); + List artists = createArtists(artistCount); + + // when + long actual = mockStageArtistsGenerator.generate(stages, artists) + .stream() + .map(StageArtist::getArtistId) + .distinct() + .count(); + + // then + assertThat(actual).isEqualTo(artistCount); + } + + private List createStages(int stageCount) { + return LongStream.rangeClosed(1, stageCount) + .mapToObj(id -> StageFixture.builder().id(id).build()) + .toList(); + } + + private List createArtists(int artistCount) { + return LongStream.rangeClosed(1, artistCount) + .mapToObj(id -> ArtistFixture.builder().id(id).build()) + .toList(); + } + + @Test + void 공연의_개수가_아티스트의_개수를_초과하면_예외() { + // given + int stageCount = 11; + int artistCount = 10; + List stages = createStages(stageCount); + List artists = createArtists(artistCount); + + // when & then + assertThatThrownBy(() -> mockStageArtistsGenerator.generate(stages, artists)) + .isInstanceOf(UnexpectedException.class); + } + + @Nested + class 각_공연에_stagePerArtist_만큼_아티스트를_참여시키는_것이_불가능해도 { + + @Test + void 각_공연마다_아티스트가_참여하는_것을_보장한다() { + // given + int stageCount = 10; + int artistCount = 10; + List stages = createStages(stageCount); + List artists = createArtists(artistCount); + + // when + List actual = mockStageArtistsGenerator.generate(stages, artists); + + // then + Map stageIdToArtistCount = actual.stream() + .collect(groupingBy(StageArtist::getStageId, counting())); + assertThat(stageIdToArtistCount.values()) + .doesNotContain(0L); + } + } +} diff --git a/backend/src/test/java/com/festago/mock/domain/MockStagesGeneratorTest.java b/backend/src/test/java/com/festago/mock/domain/MockStagesGeneratorTest.java new file mode 100644 index 000000000..28e090390 --- /dev/null +++ b/backend/src/test/java/com/festago/mock/domain/MockStagesGeneratorTest.java @@ -0,0 +1,41 @@ +package com.festago.mock.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.festival.domain.Festival; +import com.festago.stage.domain.Stage; +import com.festago.support.fixture.FestivalFixture; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MockStagesGeneratorTest { + + MockStagesGenerator mockStagesGenerator = new MockStagesGenerator(); + + @Test + void 축제의_모든_기간에_공연을_생성한다() { + // given + LocalDate now = LocalDate.parse("2077-06-30"); + Festival festival = FestivalFixture.builder() + .startDate(now) + .endDate(now.plusDays(2)) + .build(); + + // when + List actual = mockStagesGenerator.generate(festival); + + // then + LocalDate festivalStartDate = festival.getStartDate(); + LocalDate festivalEndDate = festival.getEndDate(); + long festivalPeriod = festivalStartDate.until(festivalEndDate, ChronoUnit.DAYS) + 1L; + assertThat(actual) + .size() + .isEqualTo(festivalPeriod); + } +} diff --git a/backend/src/test/java/com/festago/presentation/AdminControllerTest.java b/backend/src/test/java/com/festago/presentation/AdminControllerTest.java deleted file mode 100644 index 2fc07ecbd..000000000 --- a/backend/src/test/java/com/festago/presentation/AdminControllerTest.java +++ /dev/null @@ -1,440 +0,0 @@ -package com.festago.presentation; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.anyLong; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.festago.admin.application.AdminService; -import com.festago.auth.application.AdminAuthService; -import com.festago.auth.application.AuthExtractor; -import com.festago.auth.domain.Role; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.NotFoundException; -import com.festago.common.exception.dto.ErrorResponse; -import com.festago.festival.application.FestivalService; -import com.festago.festival.dto.FestivalCreateRequest; -import com.festago.festival.dto.FestivalResponse; -import com.festago.school.application.SchoolService; -import com.festago.school.dto.SchoolCreateRequest; -import com.festago.school.dto.SchoolResponse; -import com.festago.school.dto.SchoolUpdateRequest; -import com.festago.stage.application.StageService; -import com.festago.stage.dto.StageCreateRequest; -import com.festago.stage.dto.StageResponse; -import com.festago.stage.dto.StageUpdateRequest; -import com.festago.support.CustomWebMvcTest; -import com.festago.support.WithMockAuth; -import com.festago.ticket.application.TicketService; -import com.festago.ticket.domain.TicketType; -import com.festago.ticket.dto.TicketCreateRequest; -import com.festago.ticket.dto.TicketCreateResponse; -import jakarta.servlet.http.Cookie; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.time.LocalDateTime; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; - -@CustomWebMvcTest(AdminController.class) -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class AdminControllerTest { - - @Autowired - MockMvc mockMvc; - - @Autowired - ObjectMapper objectMapper; - - @MockBean - FestivalService festivalService; - - @MockBean - StageService stageService; - - @MockBean - TicketService ticketService; - - @MockBean - AdminService adminService; - - @MockBean - AdminAuthService adminAuthService; - - @MockBean - SchoolService schoolService; - - @SpyBean - AuthExtractor authExtractor; - - @Test - @WithMockAuth - void 토큰의_Role이_어드민이_아니면_404_NotFound() throws Exception { - // when & then - mockMvc.perform(get("/admin") - .cookie(new Cookie("token", "token"))) - .andExpect(status().isNotFound()); - } - - @Test - void 쿠키에_토큰이_없으면_404_NotFound() throws Exception { - // when & then - mockMvc.perform(get("/admin")) - .andExpect(status().isNotFound()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 축제_생성() throws Exception { - // given - String festivalName = "테코 대학교"; - String startDate = "2023-08-02"; - String endDate = "2023-08-03"; - String thumbnail = "https://picsum.photos/536/354"; - - FestivalCreateRequest request = new FestivalCreateRequest( - festivalName, - LocalDate.parse(startDate), - LocalDate.parse(endDate), - "", - 1L); - - FestivalResponse expected = new FestivalResponse( - 1L, - 1L, - festivalName, - LocalDate.parse(startDate), - LocalDate.parse(endDate), - thumbnail); - - given(festivalService.create(any())) - .willReturn(expected); - - // when && then - String content = mockMvc.perform(post("/admin/api/festivals") - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isOk()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - FestivalResponse actual = objectMapper.readValue(content, FestivalResponse.class); - assertThat(actual).isEqualTo(expected); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 존재_하지_않는_축제_무대_생성_예외() throws Exception { - // given - String startTime = "2023-07-27T18:00:00"; - String lineUp = "글렌, 애쉬, 오리, 푸우"; - String ticketOpenTime = "2023-07-26T18:00:00"; - long festivalId = 1L; - - StageCreateRequest request = new StageCreateRequest( - LocalDateTime.parse(startTime), - lineUp, - LocalDateTime.parse(ticketOpenTime), - festivalId); - - NotFoundException exception = new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND); - ErrorResponse expected = ErrorResponse.from(exception); - - given(stageService.create(any())) - .willThrow(exception); - - // when && then - String content = mockMvc.perform(post("/admin/api/stages") - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isNotFound()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - ErrorResponse actual = objectMapper.readValue(content, ErrorResponse.class); - assertThat(actual).isEqualTo(expected); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 무대_생성() throws Exception { - // given - String startTime = "2023-07-27T18:00:00"; - String lineUp = "글렌, 애쉬, 오리, 푸우"; - String ticketOpenTime = "2023-07-26T18:00:00"; - long festivalId = 1L; - - StageCreateRequest request = new StageCreateRequest( - LocalDateTime.parse(startTime), - lineUp, - LocalDateTime.parse(ticketOpenTime), - festivalId); - - StageResponse expected = new StageResponse(festivalId, festivalId, LocalDateTime.parse(startTime), - LocalDateTime.parse(ticketOpenTime), lineUp); - - given(stageService.create(any())) - .willReturn(expected); - - // when && then - String content = mockMvc.perform(post("/admin/api/stages") - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isOk()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - StageResponse actual = objectMapper.readValue(content, StageResponse.class); - assertThat(actual).isEqualTo(expected); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 무대_수정() throws Exception { - // given - String startTime = "2023-07-27T18:00:00"; - String ticketOpenTime = "2023-07-26T18:00:00"; - String lineUp = "글렌, 애쉬, 오리, 푸우"; - - StageUpdateRequest request = new StageUpdateRequest(LocalDateTime.parse(startTime), - LocalDateTime.parse(ticketOpenTime), lineUp); - - // when & then - mockMvc.perform(patch("/admin/api/stages/{id}", 1L) - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isOk()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 무대_삭제() throws Exception { - // when & then - mockMvc.perform(delete("/admin/api/stages/{id}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isOk()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 존재_하지_않는_무대_티켓_예외() throws Exception { - // given - String entryTime = "2023-07-27T18:00:00"; - - TicketCreateRequest request = new TicketCreateRequest(1L, - TicketType.VISITOR, - 100, - LocalDateTime.parse(entryTime) - ); - - NotFoundException exception = new NotFoundException(ErrorCode.STAGE_NOT_FOUND); - - ErrorResponse expected = ErrorResponse.from(exception); - - given(ticketService.create(any())) - .willThrow(exception); - - // when && then - String content = mockMvc.perform(post("/admin/api/tickets") - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isNotFound()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - ErrorResponse actual = objectMapper.readValue(content, ErrorResponse.class); - assertThat(actual).isEqualTo(expected); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 티켓_생성() throws Exception { - // given - long ticketId = 1L; - String entryTime = "2023-07-27T18:00:00"; - int totalAmount = 100; - long stageId = 1L; - TicketType ticketType = TicketType.VISITOR; - - TicketCreateRequest request = new TicketCreateRequest(stageId, - ticketType, - totalAmount, - LocalDateTime.parse(entryTime) - ); - - TicketCreateResponse expected = new TicketCreateResponse(ticketId); - - given(ticketService.create(any())) - .willReturn(expected); - - // when && then - String content = mockMvc.perform(post("/admin/api/tickets") - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isOk()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - TicketCreateResponse actual = objectMapper.readValue(content, TicketCreateResponse.class); - assertThat(actual).isEqualTo(expected); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 학교_생성() throws Exception { - // given - String domain = "teco.ac.kr"; - String name = "테코대학교"; - - SchoolCreateRequest request = new SchoolCreateRequest(domain, name); - SchoolResponse expected = new SchoolResponse(1L, domain, name); - given(schoolService.create(any(SchoolCreateRequest.class))) - .willReturn(expected); - - // when & then - String content = mockMvc.perform(post("/admin/api/schools") - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isOk()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - SchoolResponse actual = objectMapper.readValue(content, SchoolResponse.class); - assertThat(actual).isEqualTo(expected); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 학교_생성_name_null이면_에외() throws Exception { - // given - SchoolCreateRequest request = new SchoolCreateRequest("teco.ac.kr", null); - - // when & then - mockMvc.perform(post("/admin/api/schools") - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isBadRequest()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 학교_생성_domain_null이면_에외() throws Exception { - // given - SchoolCreateRequest request = new SchoolCreateRequest(null, "테코대학교"); - - // when & then - mockMvc.perform(post("/admin/api/schools") - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isBadRequest()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 학교_수정() throws Exception { - // given - SchoolUpdateRequest request = new SchoolUpdateRequest("teco.ac.kr", "테코대학교"); - - // when & then - mockMvc.perform(patch("/admin/api/schools/{id}", 1L) - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isOk()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 학교_수정_name_null이면_에외() throws Exception { - // given - SchoolUpdateRequest request = new SchoolUpdateRequest("teco.ac.kr", null); - - // when & then - mockMvc.perform(patch("/admin/api/schools/{id}", 1L) - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isBadRequest()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 학교_수정_domain_null이면_에외() throws Exception { - // given - SchoolUpdateRequest request = new SchoolUpdateRequest(null, "테코대학교"); - - // when & then - mockMvc.perform(patch("/admin/api/schools/{id}", 1L) - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isBadRequest()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 존재_하지_않는_학교_수정_예외() throws Exception { - // given - SchoolUpdateRequest request = new SchoolUpdateRequest("teco.ac.kr", "테코대학교"); - - willThrow(new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND)) - .given(schoolService).update(anyLong(), any(SchoolUpdateRequest.class)); - - // when & then - mockMvc.perform(patch("/admin/api/schools/{id}", 1L) - .content(objectMapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isNotFound()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 학교_삭제() throws Exception { - // when & then - mockMvc.perform(delete("/admin/api/schools/{id}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .cookie(new Cookie("token", "token"))) - .andDo(print()) - .andExpect(status().isOk()); - } -} diff --git a/backend/src/test/java/com/festago/presentation/AdminViewControllerTest.java b/backend/src/test/java/com/festago/presentation/AdminViewControllerTest.java deleted file mode 100644 index aa48b24cf..000000000 --- a/backend/src/test/java/com/festago/presentation/AdminViewControllerTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.festago.presentation; - -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.festago.auth.application.AuthExtractor; -import com.festago.auth.domain.Role; -import com.festago.common.exception.ErrorCode; -import com.festago.common.exception.UnauthorizedException; -import com.festago.support.CustomWebMvcTest; -import com.festago.support.WithMockAuth; -import jakarta.servlet.http.Cookie; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.SpyBean; -import org.springframework.test.web.servlet.MockMvc; - -@CustomWebMvcTest(AdminViewController.class) -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class AdminViewControllerTest { - - @Autowired - MockMvc mockMvc; - - @SpyBean - AuthExtractor authExtractor; - - @Test - void 권한이_없어도_로그인_페이지_접속_가능() throws Exception { - // when & then - mockMvc.perform(get("/admin/login")) - .andExpect(status().isOk()); - } - - @Test - @WithMockAuth - void 토큰의_만료기간이_지나면_로그인_페이지로_리다이렉트() throws Exception { - // given - given(authExtractor.extract(anyString())) - .willThrow(new UnauthorizedException(ErrorCode.EXPIRED_AUTH_TOKEN)); - - // when & then - mockMvc.perform(get("/admin/login") - .cookie(new Cookie("token", "token"))) - .andExpect(status().isOk()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 축제_관리_페이지_접속_성공() throws Exception { - // when & then - mockMvc.perform(get("/admin/festivals") - .cookie(new Cookie("token", "token"))) - .andExpect(status().isOk()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 축제_세부_관리_페이지_접속_성공() throws Exception { - // when & then - mockMvc.perform(get("/admin/festivals/{id}", 1) - .cookie(new Cookie("token", "token"))) - .andExpect(status().isOk()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 학교_관리_페이지_접속_성공() throws Exception { - // when & then - mockMvc.perform(get("/admin/schools") - .cookie(new Cookie("token", "token"))) - .andExpect(status().isOk()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 학교_세부_관리_페이지_접속_성공() throws Exception { - // when & then - mockMvc.perform(get("/admin/schools/{id}", 1) - .cookie(new Cookie("token", "token"))) - .andExpect(status().isOk()); - } - - @Test - @WithMockAuth(role = Role.ADMIN) - void 공연_세부_관리_페이지_접속_성공() throws Exception { - // when & then - mockMvc.perform(get("/admin/stages/{id}", 1) - .cookie(new Cookie("token", "token"))) - .andExpect(status().isOk()); - } -} diff --git a/backend/src/test/java/com/festago/presentation/FestivalControllerTest.java b/backend/src/test/java/com/festago/presentation/FestivalControllerTest.java deleted file mode 100644 index 12f038314..000000000 --- a/backend/src/test/java/com/festago/presentation/FestivalControllerTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.festago.presentation; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.festago.festival.application.FestivalService; -import com.festago.festival.dto.FestivalDetailResponse; -import com.festago.festival.dto.FestivalResponse; -import com.festago.festival.dto.FestivalsResponse; -import com.festago.support.CustomWebMvcTest; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.util.Collections; -import java.util.List; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; - -@CustomWebMvcTest(FestivalController.class) -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class FestivalControllerTest { - - @Autowired - MockMvc mockMvc; - - @Autowired - ObjectMapper objectMapper; - - @MockBean - FestivalService festivalService; - - @Test - void 모든_축제를_조회한다() throws Exception { - // given - FestivalResponse festivalResponse1 = new FestivalResponse(1L, 1L, "테코대학교", LocalDate.now(), - LocalDate.now().plusDays(3), "https://image1.png"); - FestivalResponse festivalResponse2 = new FestivalResponse(2L, 2L, "우테대학교", LocalDate.now().minusDays(3), - LocalDate.now(), "https://image2.png"); - FestivalsResponse expected = new FestivalsResponse(List.of(festivalResponse1, festivalResponse2)); - given(festivalService.findAll()) - .willReturn(expected); - - // when & then - String content = mockMvc.perform(get("/festivals") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - FestivalsResponse actual = objectMapper.readValue(content, FestivalsResponse.class); - assertThat(actual).isEqualTo(expected); - } - - @Test - void 축제_정보_상세_조회() throws Exception { - // given - FestivalDetailResponse expected = new FestivalDetailResponse(1L, 1L, "테코 대학교", LocalDate.now(), LocalDate.now(), - "thumbnail.png", Collections.emptyList()); - - given(festivalService.findDetail(anyLong())) - .willReturn(expected); - - // when & then - String content = mockMvc.perform(get("/festivals/{festivalId}", 1L) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - FestivalDetailResponse actual = objectMapper.readValue(content, FestivalDetailResponse.class); - assertThat(actual).isEqualTo(expected); - } -} diff --git a/backend/src/test/java/com/festago/presentation/MemberControllerTest.java b/backend/src/test/java/com/festago/presentation/MemberControllerTest.java index 741ac7220..e7ef1d1e4 100644 --- a/backend/src/test/java/com/festago/presentation/MemberControllerTest.java +++ b/backend/src/test/java/com/festago/presentation/MemberControllerTest.java @@ -4,7 +4,6 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; @@ -18,10 +17,9 @@ import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; -@CustomWebMvcTest(MemberController.class) +@CustomWebMvcTest @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") class MemberControllerTest { @@ -32,10 +30,10 @@ class MemberControllerTest { @Autowired ObjectMapper objectMapper; - @MockBean + @Autowired MemberService memberService; - @MockBean + @Autowired MemberTicketService memberTicketService; @Test @@ -51,7 +49,6 @@ class MemberControllerTest { String content = mockMvc.perform(get("/members/profile") .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) - .andDo(print()) .andReturn() .getResponse() .getContentAsString(StandardCharsets.UTF_8); diff --git a/backend/src/test/java/com/festago/presentation/MemberTicketControllerTest.java b/backend/src/test/java/com/festago/presentation/MemberTicketControllerTest.java index 5ac7a8bdb..f3684905a 100644 --- a/backend/src/test/java/com/festago/presentation/MemberTicketControllerTest.java +++ b/backend/src/test/java/com/festago/presentation/MemberTicketControllerTest.java @@ -1,7 +1,5 @@ package com.festago.presentation; -import static java.util.stream.Collectors.collectingAndThen; -import static java.util.stream.Collectors.toList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -9,13 +7,9 @@ import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; -import com.festago.entry.application.EntryService; -import com.festago.entry.dto.EntryCodeResponse; -import com.festago.stage.dto.StageResponse; import com.festago.support.CustomWebMvcTest; import com.festago.support.WithMockAuth; import com.festago.ticketing.application.MemberTicketService; @@ -28,17 +22,17 @@ import com.festago.ticketing.dto.TicketingResponse; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; -import java.util.stream.LongStream; +import java.util.List; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -@CustomWebMvcTest(MemberTicketController.class) +// TODO 해당 테스트 코드 개선 또는 컨트롤러 새롭게 설계 필요 +@CustomWebMvcTest @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") class MemberTicketControllerTest { @@ -49,41 +43,12 @@ class MemberTicketControllerTest { @Autowired ObjectMapper objectMapper; - @MockBean - EntryService entryService; - - @MockBean + @Autowired MemberTicketService memberTicketService; - @MockBean + @Autowired TicketingService ticketingService; - @Test - @WithMockAuth - void QR을_생성한다() throws Exception { - // given - Long memberTicketId = 1L; - String code = "2312313213"; - long period = 30; - String token = "sampleToken"; - - EntryCodeResponse expected = new EntryCodeResponse(code, period); - - given(entryService.createEntryCode(anyLong(), anyLong())) - .willReturn(expected); - - // when & then - String content = mockMvc.perform(post("/member-tickets/{memberTicketId}/qr", memberTicketId) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token)) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - EntryCodeResponse actual = objectMapper.readValue(content, EntryCodeResponse.class); - assertThat(actual).isEqualTo(expected); - } - @Test @WithMockAuth void 단일_티켓을_조회한다() throws Exception { @@ -92,26 +57,21 @@ class MemberTicketControllerTest { Long memberId = 1L; String token = "sampleToken"; - StageResponse stageResponse = new StageResponse(1L, 1L, LocalDateTime.now(), LocalDateTime.now(), "푸우회장"); - MemberTicketFestivalResponse festivalResponse = new MemberTicketFestivalResponse(1L, "테코대학교", - "https://image.png"); - MemberTicketResponse expected = new MemberTicketResponse(memberTicketId, 1, LocalDateTime.now(), - EntryState.BEFORE_ENTRY, LocalDateTime.now(), stageResponse, festivalResponse); - given(memberTicketService.findById(memberId, memberTicketId)) - .willReturn(expected); + .willReturn(new MemberTicketResponse( + 1L, + 1, + LocalDateTime.now(), + EntryState.BEFORE_ENTRY, + LocalDateTime.now(), + new MemberTicketFestivalResponse(1L, "테코대학교 축제", "https://image.com/posterImage.png") + )); // when & then - String content = mockMvc.perform(get("/member-tickets/{memberTicketId}", memberTicketId) + mockMvc.perform(get("/member-tickets/{memberTicketId}", memberTicketId) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token)) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - MemberTicketResponse actual = objectMapper.readValue(content, MemberTicketResponse.class); - assertThat(actual).isEqualTo(expected); + .andExpect(status().isOk()); } @Test @@ -121,29 +81,16 @@ class MemberTicketControllerTest { Long memberId = 1L; String token = "sampleToken"; - StageResponse stageResponse = new StageResponse(1L, 1L, LocalDateTime.now(), LocalDateTime.now(), "푸우회장"); - MemberTicketFestivalResponse festivalResponse = new MemberTicketFestivalResponse(1L, "테코대학교", - "https://image.png"); - MemberTicketsResponse expected = LongStream.range(0, 10L) - .mapToObj( - it -> new MemberTicketResponse(it, 1, LocalDateTime.now(), EntryState.BEFORE_ENTRY, LocalDateTime.now(), - stageResponse, festivalResponse)) - .collect(collectingAndThen(toList(), MemberTicketsResponse::new)); - given(memberTicketService.findAll(eq(memberId), any(Pageable.class))) - .willReturn(expected); + .willReturn(new MemberTicketsResponse( + List.of() + )); // when & then - String content = mockMvc.perform(get("/member-tickets") + mockMvc.perform(get("/member-tickets") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token)) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - MemberTicketsResponse actual = objectMapper.readValue(content, MemberTicketsResponse.class); - assertThat(actual).isEqualTo(expected); + .andExpect(status().isOk()); } @Test @@ -153,29 +100,14 @@ class MemberTicketControllerTest { Long memberId = 1L; String token = "sampleToken"; - StageResponse stageResponse = new StageResponse(1L, 1L, LocalDateTime.now(), LocalDateTime.now(), "푸우회장"); - MemberTicketFestivalResponse festivalResponse = new MemberTicketFestivalResponse(1L, "테코대학교", - "https://image.png"); - MemberTicketsResponse expected = LongStream.range(0, 10L) - .mapToObj( - it -> new MemberTicketResponse(it, 1, LocalDateTime.now(), EntryState.BEFORE_ENTRY, LocalDateTime.now(), - stageResponse, festivalResponse)) - .collect(collectingAndThen(toList(), MemberTicketsResponse::new)); - given(memberTicketService.findCurrent(eq(memberId), any(Pageable.class))) - .willReturn(expected); + .willReturn(new MemberTicketsResponse(List.of())); // when & then - String content = mockMvc.perform(get("/member-tickets/current") + mockMvc.perform(get("/member-tickets/current") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token)) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - MemberTicketsResponse actual = objectMapper.readValue(content, MemberTicketsResponse.class); - assertThat(actual).isEqualTo(expected); + .andExpect(status().isOk()); } @Test @@ -200,7 +132,6 @@ class MemberTicketControllerTest { .content(objectMapper.writeValueAsString(request)) .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) - .andDo(print()) .andReturn() .getResponse() .getContentAsString(StandardCharsets.UTF_8); diff --git a/backend/src/test/java/com/festago/presentation/SchoolControllerTest.java b/backend/src/test/java/com/festago/presentation/SchoolControllerTest.java deleted file mode 100644 index c7cded0b3..000000000 --- a/backend/src/test/java/com/festago/presentation/SchoolControllerTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.festago.presentation; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.festago.school.application.SchoolService; -import com.festago.school.dto.SchoolResponse; -import com.festago.school.dto.SchoolsResponse; -import com.festago.support.CustomWebMvcTest; -import java.nio.charset.StandardCharsets; -import java.util.List; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; - -@CustomWebMvcTest(SchoolController.class) -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class SchoolControllerTest { - - @Autowired - MockMvc mockMvc; - - @Autowired - ObjectMapper objectMapper; - - @MockBean - SchoolService schoolService; - - @Test - void 모든_학교_정보_조회() throws Exception { - // given - SchoolsResponse expected = new SchoolsResponse( - List.of( - new SchoolResponse(1L, "pooh.ac.kr", "푸우대학"), - new SchoolResponse(2L, "ash.ac.kr", "애쉬대학") - )); - - given(schoolService.findAll()) - .willReturn(expected); - - // when & then - String content = mockMvc.perform(get("/schools") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - SchoolsResponse actual = objectMapper.readValue(content, SchoolsResponse.class); - assertThat(actual).isEqualTo(expected); - } - - @Test - void 단일_학교_정보_조회() throws Exception { - // given - SchoolResponse expected = new SchoolResponse(1L, "teco.ac.kr", "테코대학"); - - given(schoolService.findById(expected.id())) - .willReturn(expected); - - // when & then - String content = mockMvc.perform(get("/schools/{id}", 1L) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - SchoolResponse actual = objectMapper.readValue(content, SchoolResponse.class); - assertThat(actual).isEqualTo(expected); - } -} diff --git a/backend/src/test/java/com/festago/presentation/StaffMemberTicketControllerTest.java b/backend/src/test/java/com/festago/presentation/StaffMemberTicketControllerTest.java index 0bcdd1a26..f7b7932b7 100644 --- a/backend/src/test/java/com/festago/presentation/StaffMemberTicketControllerTest.java +++ b/backend/src/test/java/com/festago/presentation/StaffMemberTicketControllerTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; @@ -17,12 +16,11 @@ import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -@CustomWebMvcTest(StaffMemberTicketController.class) +@CustomWebMvcTest @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") class StaffMemberTicketControllerTest { @@ -33,7 +31,7 @@ class StaffMemberTicketControllerTest { @Autowired ObjectMapper objectMapper; - @MockBean + @Autowired EntryService entryService; @Test @@ -49,7 +47,6 @@ class StaffMemberTicketControllerTest { .content(objectMapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andDo(print()) .andReturn() .getResponse() .getContentAsString(StandardCharsets.UTF_8); diff --git a/backend/src/test/java/com/festago/presentation/StageControllerTest.java b/backend/src/test/java/com/festago/presentation/StageControllerTest.java deleted file mode 100644 index 988c245de..000000000 --- a/backend/src/test/java/com/festago/presentation/StageControllerTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.festago.presentation; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.festago.stage.application.StageService; -import com.festago.stage.dto.StageResponse; -import com.festago.support.CustomWebMvcTest; -import com.festago.ticket.application.TicketService; -import com.festago.ticket.domain.TicketType; -import com.festago.ticket.dto.StageTicketResponse; -import com.festago.ticket.dto.StageTicketsResponse; -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.util.List; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; - -@CustomWebMvcTest(StageController.class) -@DisplayNameGeneration(ReplaceUnderscores.class) -@SuppressWarnings("NonAsciiCharacters") -class StageControllerTest { - - @Autowired - MockMvc mockMvc; - - @Autowired - ObjectMapper objectMapper; - - @MockBean - TicketService ticketService; - - @MockBean - StageService stageService; - - @Test - void 공연의_티켓_정보를_조회() throws Exception { - // given - StageTicketsResponse expected = new StageTicketsResponse( - List.of( - new StageTicketResponse(1L, TicketType.STUDENT, 100, 60), - new StageTicketResponse(2L, TicketType.VISITOR, 50, 30) - )); - - given(ticketService.findStageTickets(anyLong())) - .willReturn(expected); - - // when & then - String content = mockMvc.perform(get("/stages/{stageId}/tickets", 1L) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - StageTicketsResponse actual = objectMapper.readValue(content, StageTicketsResponse.class); - assertThat(actual).isEqualTo(expected); - } - - @Test - void 공연의_정보를_조회() throws Exception { - // given - StageResponse expected = new StageResponse(1L, 1L, LocalDateTime.now(), LocalDateTime.now(), "푸우회장"); - given(stageService.findDetail(anyLong())) - .willReturn(expected); - - // when & then - String content = mockMvc.perform(get("/stages/{stageId}", 1L) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(print()) - .andReturn() - .getResponse() - .getContentAsString(StandardCharsets.UTF_8); - StageResponse actual = objectMapper.readValue(content, StageResponse.class); - assertThat(actual).isEqualTo(expected); - } -} diff --git a/backend/src/test/java/com/festago/presentation/StudentControllerTest.java b/backend/src/test/java/com/festago/presentation/StudentControllerTest.java index 541d79f40..07b1bac77 100644 --- a/backend/src/test/java/com/festago/presentation/StudentControllerTest.java +++ b/backend/src/test/java/com/festago/presentation/StudentControllerTest.java @@ -1,27 +1,33 @@ package com.festago.presentation; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; import com.festago.student.application.StudentService; +import com.festago.student.dto.StudentResponse; +import com.festago.student.dto.StudentSchoolResponse; import com.festago.student.dto.StudentSendMailRequest; import com.festago.student.dto.StudentVerificateRequest; import com.festago.support.CustomWebMvcTest; import com.festago.support.WithMockAuth; +import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +@CustomWebMvcTest @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") -@CustomWebMvcTest(StudentController.class) class StudentControllerTest { @Autowired @@ -30,7 +36,7 @@ class StudentControllerTest { @Autowired ObjectMapper objectMapper; - @MockBean + @Autowired StudentService studentService; @Nested @@ -93,4 +99,41 @@ class 학생_인증 { .andExpect(status().isOk()); } } + + @Nested + class 학생_인증_정보_조회 { + + @Test + void 인증이_되지_않으면_401() throws Exception { + // given + StudentVerificateRequest request = new StudentVerificateRequest("123456"); + + // when & then + mockMvc.perform(get("/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockAuth + void 학생_인증_조회() throws Exception { + // given + StudentResponse expected = new StudentResponse(true, new StudentSchoolResponse(1L, "테코대학교", "teco.ac.kr")); + given(studentService.findVerification(anyLong())) + .willReturn(expected); + + // when & then + String content = mockMvc.perform(get("/students") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer token") + ) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + StudentResponse actual = objectMapper.readValue(content, StudentResponse.class); + assertThat(actual).isEqualTo(expected); + } + } } diff --git a/backend/src/test/java/com/festago/school/application/SchoolCommandServiceTest.java b/backend/src/test/java/com/festago/school/application/SchoolCommandServiceTest.java new file mode 100644 index 000000000..3b8919481 --- /dev/null +++ b/backend/src/test/java/com/festago/school/application/SchoolCommandServiceTest.java @@ -0,0 +1,197 @@ +package com.festago.school.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.*; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.dto.command.SchoolCreateCommand; +import com.festago.school.dto.command.SchoolUpdateCommand; +import com.festago.school.repository.MemorySchoolRepository; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.fixture.SchoolFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class SchoolCommandServiceTest { + + SchoolCommandService schoolCommandService; + + SchoolRepository schoolRepository; + + @BeforeEach + void setUp() { + schoolRepository = new MemorySchoolRepository(); + schoolCommandService = new SchoolCommandService(schoolRepository, mock()); + } + + @Nested + class createSchool { + + SchoolCreateCommand command = SchoolCreateCommand.builder() + .name("테코대학교") + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .build(); + + @Test + void 같은_도메인의_학교가_저장되어_있어도_예외가_발생하지_않는다() { + // given + schoolRepository.save(SchoolFixture.builder().domain("teco.ac.kr").build()); + + // when + Long schoolId = schoolCommandService.createSchool(command); + + // then + assertThat(schoolRepository.getOrThrow(schoolId).getDomain()).isEqualTo("teco.ac.kr"); + } + + @Test + void 같은_이름의_학교가_저장되어_있으면_예외가_발생한다() { + // given + schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + + // when & then + assertThatThrownBy(() -> schoolCommandService.createSchool(command)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DUPLICATE_SCHOOL_NAME.getMessage()); + } + + @Test + void 예외가_발생하지_않으면_학교가_저장된다() { + // when + Long schoolId = schoolCommandService.createSchool(command); + + // then + assertThat(schoolRepository.findById(schoolId)).isPresent(); + } + } + + @Nested + class updateSchool { + + School school; + SchoolUpdateCommand command = SchoolUpdateCommand.builder() + .name("테코대학교") + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .logoUrl("https://image.com/newLogo.png") + .backgroundImageUrl("https://image.com/newBackgroundImage.png") + .build(); + + @BeforeEach + void setUp() { + school = schoolRepository.save(SchoolFixture.builder() + .name("우테대학교") + .domain("wote.ac.kr") + .region(SchoolRegion.대구) + .logoUrl("https://image.com/logo.png") + .backgroundImageUrl("https://image.com/backgroundImage.png") + .build() + ); + } + + @Test + void 식별자에_대한_학교를_찾을수_없으면_예외가_발생한다() { + // given + Long schoolId = 4885L; + + // when & then + assertThatThrownBy(() -> schoolCommandService.updateSchool(schoolId, command)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.SCHOOL_NOT_FOUND.getMessage()); + } + + @Test + void 같은_도메인의_학교가_저장되어_있어도_예외가_발생하지_않는다() { + // given + Long schoolId = school.getId(); + schoolRepository.save(SchoolFixture.builder().domain("teco.ac.kr").build()); + + // when + schoolCommandService.updateSchool(schoolId, command); + + // then + assertThat(schoolRepository.getOrThrow(schoolId).getDomain()).isEqualTo("teco.ac.kr"); + } + + @Test + void 같은_이름의_학교가_저장되어_있으면_예외가_발생한다() { + // given + Long schoolId = school.getId(); + schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + + // when & then + assertThatThrownBy(() -> schoolCommandService.updateSchool(schoolId, command)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DUPLICATE_SCHOOL_NAME.getMessage()); + } + + @Test + void 수정할_이름이_수정할_학교의_이름과_같으면_이름은_수정되지_않는다() { + // given + Long schoolId = school.getId(); + var command = SchoolUpdateCommand.builder() + .name(school.getName()) + .domain("teco.ac.kr") + .region(SchoolRegion.서울) + .build(); + + // when + schoolCommandService.updateSchool(schoolId, command); + + // then + School updatedSchool = schoolRepository.getOrThrow(schoolId); + assertThat(updatedSchool.getName()).isEqualTo(school.getName()); + } + + @Test + void 수정할_도메인이_수정할_학교의_도메인과_같으면_도메인은_수정되지_않는다() { + // given + Long schoolId = school.getId(); + var command = SchoolUpdateCommand.builder() + .name("테코대학교") + .domain(school.getDomain()) + .region(SchoolRegion.서울) + .build(); + + // when + schoolCommandService.updateSchool(schoolId, command); + + // then + School updatedSchool = schoolRepository.getOrThrow(schoolId); + assertThat(updatedSchool.getDomain()).isEqualTo(school.getDomain()); + } + + @Test + void 예외가_발생하지_않으면_학교가_수정된다() { + // given + Long schoolId = school.getId(); + + // when + schoolCommandService.updateSchool(schoolId, command); + + // then + School updatedSchool = schoolRepository.getOrThrow(schoolId); + assertSoftly(softly -> { + softly.assertThat(updatedSchool.getName()).isEqualTo("테코대학교"); + softly.assertThat(updatedSchool.getDomain()).isEqualTo("teco.ac.kr"); + softly.assertThat(updatedSchool.getRegion()).isEqualTo(SchoolRegion.서울); + softly.assertThat(updatedSchool.getLogoUrl()).isEqualTo("https://image.com/newLogo.png"); + softly.assertThat(updatedSchool.getBackgroundUrl()) + .isEqualTo("https://image.com/newBackgroundImage.png"); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/school/application/integration/SchoolDeleteServiceIntegrationTest.java b/backend/src/test/java/com/festago/school/application/integration/SchoolDeleteServiceIntegrationTest.java new file mode 100644 index 000000000..d19b8a6ab --- /dev/null +++ b/backend/src/test/java/com/festago/school/application/integration/SchoolDeleteServiceIntegrationTest.java @@ -0,0 +1,103 @@ +package com.festago.school.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.member.domain.Member; +import com.festago.member.repository.MemberRepository; +import com.festago.school.application.SchoolDeleteService; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.student.domain.Student; +import com.festago.student.repository.StudentRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StudentFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * TODO + * 서비스 통합 테스트 시 필요한 의존이 너무 많은 것 같다. 차라리 Cucumber를 사용한 통합 테스트로 해당 테스트가 하는 역할을 옮기고, 서비스 통합 테스트는 Stub을 사용한 단위 테스트로 변경하여 + * 서비스가 행하고자 하는 비즈니스 로직을 나타내는 테스트로 하는게 어떨까 deleteSchool() 메서드의 행위는 Validator.validate()를 호출하여, 던져진 예외가 있으면 삭제에 실패한다. 라고 + * 볼 수 있다. + */ +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class SchoolDeleteServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + SchoolDeleteService schoolDeleteService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StudentRepository studentRepository; + + @Autowired + MemberRepository memberRepository; + + @Nested + class deleteSchool { + + School school; + + @BeforeEach + void setUp() { + school = schoolRepository.save(SchoolFixture.builder().build()); + } + + @Test + void 학교에_등록된_축제가_있으면_삭제에_실패한다() { + // given + Long schoolId = school.getId(); + Festival festival = FestivalFixture.builder().school(school).build(); + festivalRepository.save(festival); + + // when & then + assertThatThrownBy(() -> schoolDeleteService.deleteSchool(schoolId)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.SCHOOL_DELETE_CONSTRAINT_EXISTS_FESTIVAL.getMessage()); + } + + @Test + void 학교에_등록된_학생이_있으면_삭제에_실패한다() { + // given + Long schoolId = school.getId(); + Member member = memberRepository.save(MemberFixture.builder().build()); + Student student = StudentFixture.builder().member(member).school(school).build(); + studentRepository.save(student); + + // when & then + assertThatThrownBy(() -> schoolDeleteService.deleteSchool(schoolId)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.SCHOOL_DELETE_CONSTRAINT_EXISTS_STUDENT.getMessage()); + } + + @Test + void Validator의_검증이_정상이면_학교가_삭제된다() { + // given + Long schoolId = school.getId(); + + // when + schoolDeleteService.deleteSchool(schoolId); + + // then + assertThat(schoolRepository.findById(1L)).isEmpty(); + } + } +} diff --git a/backend/src/test/java/com/festago/school/application/integration/SchoolSearchV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/school/application/integration/SchoolSearchV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..752de4595 --- /dev/null +++ b/backend/src/test/java/com/festago/school/application/integration/SchoolSearchV1QueryServiceIntegrationTest.java @@ -0,0 +1,79 @@ +package com.festago.school.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.school.application.v1.SchoolSearchV1QueryService; +import com.festago.school.domain.School; +import com.festago.school.dto.v1.SchoolSearchV1Response; +import com.festago.school.repository.SchoolRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.SchoolFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class SchoolSearchV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + SchoolSearchV1QueryService schoolSearchV1QueryService; + + @Autowired + SchoolRepository schoolRepository; + + School 테코대학교; + + School 테코여자대학교; + + School 우테대학교; + + @BeforeEach + void setUp() { + 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").domain("teco.ac.kr").build()); + 테코여자대학교 = schoolRepository.save(SchoolFixture.builder().name("테코여자대학교").domain("tecowoman.ac.kr").build()); + 우테대학교 = schoolRepository.save(SchoolFixture.builder().name("우테대학교").domain("woote.ac.kr").build()); + } + + @Test + void 우테대학교를_검색하면_우테대학교가_검색되어야_한다() { + // given + String keyword = "우테대학교"; + + // when + var response = schoolSearchV1QueryService.searchSchools(keyword); + + // then + assertThat(response) + .map(SchoolSearchV1Response::id) + .containsExactly(우테대학교.getId()); + } + + @Test + void 테코를_검색하면_테코대학교와_테코여자대학교가_검색되어야_한다() { + // given + String keyword = "테코"; + + // when + var response = schoolSearchV1QueryService.searchSchools(keyword); + + // then + assertThat(response) + .map(SchoolSearchV1Response::id) + .containsExactly(테코대학교.getId(), 테코여자대학교.getId()); + } + + @Test + void 학교의_이름에_포함되지_않으면_빈_리스트가_반환된다() { + // given + String keyword = "글렌"; + + // when + var response = schoolSearchV1QueryService.searchSchools(keyword); + + // then + assertThat(response).isEmpty(); + } +} diff --git a/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java b/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java new file mode 100644 index 000000000..5dc0da5d3 --- /dev/null +++ b/backend/src/test/java/com/festago/school/application/integration/SchoolV1QueryServiceIntegrationTest.java @@ -0,0 +1,285 @@ +package com.festago.school.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.application.v1.SchoolV1QueryService; +import com.festago.school.domain.School; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.school.repository.SchoolRepository; +import com.festago.school.repository.v1.SchoolFestivalV1SearchCondition; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.repository.SocialMediaRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.SocialMediaFixture; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolV1QueryServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + SchoolV1QueryService schoolV1QueryService; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + SocialMediaRepository socialMediaRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Nested + class 학교_상세_정보_조회 { + + @Test + void 해당하는_학교가_존재하지_않으면_예외() { + // when && then + assertThatThrownBy(() -> schoolV1QueryService.findDetailById(-1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 학교입니다."); + } + + @Test + void 학교에_소셜미디어가_존재하지_않아도_조회된다() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + + // when + SchoolDetailV1Response actual = schoolV1QueryService.findDetailById(school.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).isNotNull(); + softly.assertThat(actual.socialMedias()).isEmpty(); + }); + } + + @Test + void 아티스트의_소셜미디어는_아이디가_같아도_조회되지_않는다() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + saveSocialMedia(school.getId(), OwnerType.SCHOOL, SocialMediaType.X); + saveSocialMedia(school.getId(), OwnerType.ARTIST, SocialMediaType.YOUTUBE); + + // when + SchoolDetailV1Response actual = schoolV1QueryService.findDetailById(school.getId()); + + // then + assertThat(actual.socialMedias()).hasSize(1); + } + + @Test + void 학교와_포함된_소셜미디어를_모두_조회한다() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + saveSocialMedia(school.getId(), OwnerType.SCHOOL, SocialMediaType.X); + saveSocialMedia(school.getId(), OwnerType.SCHOOL, SocialMediaType.YOUTUBE); + + // when + SchoolDetailV1Response actual = schoolV1QueryService.findDetailById(school.getId()); + + // then + assertThat(actual.socialMedias()).hasSize(2); + } + + private void saveSocialMedia(Long ownerId, OwnerType ownerType, SocialMediaType mediaType) { + socialMediaRepository.save( + SocialMediaFixture.builder() + .ownerId(ownerId) + .ownerType(ownerType) + .mediaType(mediaType) + .build() + ); + } + } + + @Nested + class 학교별_축제_페이징_조회 { + + School school; + LocalDate today = LocalDate.now(); + + @BeforeEach + void setUp() { + school = schoolRepository.save(SchoolFixture.builder().build()); + } + + @Test + void 과거_축제만_가져온다() { + // given + + // 진행중 + saveFestival(today, today.plusDays(1)); + saveFestival(today, today.plusDays(1)); + + // 진행예정 + saveFestival(today.plusDays(1), today.plusDays(2)); + saveFestival(today.plusDays(1), today.plusDays(2)); + + // 종료 + Festival lastFestival = saveFestival(today.minusDays(3), today.minusDays(1)); + + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, true, Pageable.ofSize(10)); + // when + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); + + // then + assertThat(actual).hasSize(1); + assertThat(actual.get(0).id()).isEqualTo(lastFestival.getId()); + } + + @Test + void 현재_혹은_예정_축제만_가져온다() { + // given + + // 진행 혹은 예정 축제 + saveFestival(today, today.plusDays(1)); + saveFestival(today.plusDays(1), today.plusDays(2)); + + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(10)); + + // 종료 축제 + saveFestival(today.minusDays(3), today.minusDays(1)); + + // when + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); + + // then + assertThat(actual).hasSize(2); + } + + @Test + void 현재_축제를_시작일자가_빠른순으로_조회한다() { + // given + saveFestival(today.plusDays(2), today.plusDays(3)); + saveFestival(today.plusDays(2), today.plusDays(3)); + Festival recentFestival = saveFestival(today, today.plusDays(1)); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(10)); + + // when + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); + + // then + assertThat(actual.get(0).id()).isEqualTo(recentFestival.getId()); + } + + @Test + void 과거_축제를_종료일자가_느린순으로_조회한다() { + // given + saveFestival(today.minusDays(4), today.minusDays(3)); + saveFestival(today.minusDays(3), today.minusDays(2)); + Festival recentFestival = saveFestival(today.minusDays(3), today.minusDays(1)); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, true, Pageable.ofSize(10)); + + // when + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); + + // then + assertThat(actual.get(0).id()).isEqualTo(recentFestival.getId()); + } + + @Test + void 페이징하여_현재_축제를_조회한다() { + // given + saveFestival(today, today.plusDays(3)); + Festival nextPageFirstReadFestival = saveFestival(today.plusDays(1), today.plusDays(1)); + Festival lastReadFestival = saveFestival(today, today.plusDays(1)); + saveFestival(today.plusDays(1), today.plusDays(1)); + saveFestival(today.plusDays(2), today.plusDays(2)); + var searchCondition = new SchoolFestivalV1SearchCondition( + lastReadFestival.getId(), lastReadFestival.getStartDate(), false, Pageable.ofSize(2)); + + // when + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); + + // then + assertThat(actual).hasSize(2); + assertThat(actual.get(0).id()).isEqualTo(nextPageFirstReadFestival.getId()); + } + + @Test + void 페이징하여_과거_축제를_조회한다() { + // given + LocalDate yesterday = today.minusDays(1); + + saveFestival(yesterday.minusDays(2), yesterday); + Festival nextPageFirstReadFestival = saveFestival(yesterday.minusDays(3), yesterday); + Festival lastReadFestival = saveFestival(yesterday.minusDays(2), yesterday); + saveFestival(yesterday.minusDays(4), yesterday); + saveFestival(yesterday.minusDays(4), yesterday); + var searchCondition = new SchoolFestivalV1SearchCondition( + lastReadFestival.getId(), lastReadFestival.getStartDate(), true, Pageable.ofSize(2)); + + // when + List actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition).getContent(); + + // then + assertThat(actual).hasSize(2); + assertThat(actual.get(0).id()).isEqualTo(nextPageFirstReadFestival.getId()); + } + + @Test + void 다음_페이지가_존재한다() { + // given + saveFestival(today, today.plusDays(1)); + saveFestival(today, today.plusDays(1)); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(1)); + + // when + Slice actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, + searchCondition); + + // then + assertThat(actual.hasNext()).isTrue(); + } + + @Test + void 다음_페이지가_존재하지_않는다() { + // given + saveFestival(today, today.plusDays(1)); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(1)); + + // when + Slice actual = schoolV1QueryService.findFestivalsBySchoolId( + school.getId(), today, searchCondition); + + // then + assertThat(actual.hasNext()).isFalse(); + } + + private Festival saveFestival(LocalDate startDate, LocalDate endDate) { + return festivalRepository.save( + FestivalFixture.builder() + .startDate(startDate) + .endDate(endDate) + .school(school) + .build()); + } + } +} diff --git a/backend/src/test/java/com/festago/school/domain/SchoolTest.java b/backend/src/test/java/com/festago/school/domain/SchoolTest.java index f3305223f..b398cc89f 100644 --- a/backend/src/test/java/com/festago/school/domain/SchoolTest.java +++ b/backend/src/test/java/com/festago/school/domain/SchoolTest.java @@ -2,82 +2,361 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import com.festago.common.exception.ValidException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") class SchoolTest { - @Test - void 학교의_도메인_길이가_50자를_넘으면_예외() { - // given - String domain = "1234567890".repeat(5) + "1"; + @Nested + class 생성 { - // when & then - assertThatThrownBy(() -> new School(domain, "name")) - .isInstanceOf(IllegalArgumentException.class); - } + @Test + void 도메인이_50자를_넘으면_예외() { + // given + String domain = "1".repeat(51); - @Test - void 학교의_이름이_255자를_넘으면_예외() { - // given - String name = "1234567890".repeat(25) + "123456"; + // when & then + assertThatThrownBy(() -> new School(domain, "테코대학교", "", "", SchoolRegion.서울)) + .isInstanceOf(ValidException.class); + } - // when & then - assertThatThrownBy(() -> new School("domain", name)) - .isInstanceOf(IllegalArgumentException.class); - } + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void 도메인이_null_또는_공백이면_예외(String domain) { + // when & then + assertThatThrownBy(() -> new School(domain, "테코대학교", "", "", SchoolRegion.서울)) + .isInstanceOf(ValidException.class); + } - @Test - void 학교의_도메인을_수정할때_255자를_넘으면_예외() { - // given - School school = new School("domain", "name"); + @ParameterizedTest + @ValueSource(ints = {1, 50}) + void 도메인이_50자_이내이면_성공(int length) { + // given + String domain = "1".repeat(length); - // when & then - String domain = "1234567890".repeat(5) + "1"; - assertThatThrownBy(() -> school.changeDomain(domain)) - .isInstanceOf(IllegalArgumentException.class); - } + // when + School school = new School(domain, "테코대학교", "", "", SchoolRegion.서울); - @Test - void 학교의_이름을_수정할때_255자를_넘으면_예외() { - // given - School school = new School("domain", "name"); + // then + assertThat(school.getDomain()).isEqualTo(domain); + } - // when & then - String name = "1234567890".repeat(25) + "123456"; - assertThatThrownBy(() -> school.changeName(name)) - .isInstanceOf(IllegalArgumentException.class); - } + @Test + void 이름이_255자를_넘으면_예외() { + // given + String name = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> new School("teco.ac.kr", name, "", "", SchoolRegion.서울)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void 이름이_null_또는_공백이면_예외(String name) { + // when & then + assertThatThrownBy(() -> new School("teco.ac.kr", name, "", "", SchoolRegion.서울)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void 이름이_255자_이내이면_성공(int length) { + // given + String name = "1".repeat(length); + + // when + School school = new School("teco.ac.kr", name, "", "", SchoolRegion.서울); + + // then + assertThat(school.getName()).isEqualTo(name); + } + + @Test + void 지역이_null이면_예외() { + // given + SchoolRegion region = null; + + // when & then + assertThatThrownBy(() -> new School("teco.ac.kr", "테코대학교", "", "", region)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void logoUrl이_null_또는_공백이면_기본값이_할당된다(String logoUrl) { + // when + School school = new School(1L, "teco.ac.kr", "테코대학교", logoUrl, "https://image.com/backgroundImage.png", + SchoolRegion.서울); + + // then + assertThat(school.getLogoUrl()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void logoUrl이_255자_이내이면_성공(int length) { + // given + String logoUrl = "1".repeat(length); - @ParameterizedTest - @ValueSource(ints = {1, 50}) - void 학교를_생성할때_도메인이_50글자_이내_성공(int length) { - // given - String domain = "1".repeat(length); + // when + School school = new School(1L, "teco.ac.kr", "테코대학교", logoUrl, "https://image.com/backgroundImage.png", + SchoolRegion.서울); - // when - School school = new School(domain, "name"); + // then + assertThat(school.getLogoUrl()).isEqualTo(logoUrl); + } - // then - assertThat(school.getDomain()).isEqualTo(domain); + @Test + void logoUrl이_255자를_넘으면_예외() { + // given + String logoUrl = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> { + new School(1L, "teco.ac.kr", "테코대학교", logoUrl, "https://image.com/backgroundImage.png", + SchoolRegion.서울); + }).isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void backgroundImageUrl이_null_또는_공백이면_기본값이_할당된다(String backgroundImageUrl) { + // when + School school = new School(1L, "teco.ac.kr", "테코대학교", "https://image.com/logo.png", backgroundImageUrl, + SchoolRegion.서울); + + // then + assertThat(school.getBackgroundUrl()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void backgroundImageUrl이_255자_이내이면_성공(int length) { + // given + String backgroundImageUrl = "1".repeat(length); + + // when + School school = new School(1L, "teco.ac.kr", "테코대학교", "https://image.com/logo.png", backgroundImageUrl, + SchoolRegion.서울); + + // then + assertThat(school.getBackgroundUrl()).isEqualTo(backgroundImageUrl); + } + + @Test + void backgroundImageUrl이_255자를_넘으면_예외() { + // given + String backgroundImageUrl = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> { + new School(1L, "teco.ac.kr", "테코대학교", "https://image.com/logo.png", backgroundImageUrl, + SchoolRegion.서울); + }).isInstanceOf(ValidException.class); + } + + // TODO 해당 테스트는 생성자 파라미터 순서가 올바른지 검사하는데 의의가 있음 + // 다만 빌더 패턴을 적용하면 해당 테스트의 필요성이 있을까? + @Test + void 성공() { + // given + Long id = 1L; + String domain = "teco.ac.kr"; + String name = "테코대학교"; + String logoUrl = "https://image.com/logo.png"; + String backgroundImageUrl = "https://image.com/backgroundImage.png"; + SchoolRegion region = SchoolRegion.서울; + + School school = new School(id, domain, name, logoUrl, backgroundImageUrl, region); + + // when & then + assertSoftly(softly -> { + softly.assertThat(school.getId()).isEqualTo(1L); + softly.assertThat(school.getDomain()).isEqualTo(domain); + softly.assertThat(school.getName()).isEqualTo(name); + softly.assertThat(school.getLogoUrl()).isEqualTo(logoUrl); + softly.assertThat(school.getBackgroundUrl()).isEqualTo(backgroundImageUrl); + softly.assertThat(school.getRegion()).isEqualTo(region); + }); + } } - @ParameterizedTest - @ValueSource(ints = {1, 255}) - void 학교를_생성할때_이름이_255글자_이내_성공(int length) { - // given - String name = "1".repeat(length); + @Nested + class 수정 { + + School school; + + @BeforeEach + void setUp() { + school = new School(1L, "teco.ac.kr", "테코대학교", "https://image.com/logo.png", + "https://image.com/backgroundImage.png", SchoolRegion.서울); + } + + @Test + void 도메인이_51자를_넘으면_예외() { + // given + String domain = "1".repeat(51); + + // when & then + assertThatThrownBy(() -> school.changeDomain(domain)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void 도메인이_null_또는_공백이면_예외(String domain) { + // when & then + assertThatThrownBy(() -> school.changeDomain(domain)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @ValueSource(ints = {1, 50}) + void 도메인이_50자_이내이면_성공(int length) { + // given + String domain = "1".repeat(length); + + // when + school.changeDomain(domain); + + // then + assertThat(school.getDomain()).isEqualTo(domain); + } + + @Test + void 이름이_255자를_넘으면_예외() { + // when & then + String name = "1".repeat(256); + assertThatThrownBy(() -> school.changeName(name)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void 이름이_null_또는_공백이면_예외(String name) { + // when & then + assertThatThrownBy(() -> school.changeName(name)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void 이름이_255자_이내이면_성공(int length) { + // given + String name = "1".repeat(length); + + // when + school.changeName(name); + + // then + assertThat(school.getName()).isEqualTo(name); + } + + @Test + void 지역이_null이면_예외() { + // given + SchoolRegion region = null; + + // when & then + assertThatThrownBy(() -> school.changeRegion(region)) + .isInstanceOf(ValidException.class); + } + + @Test + void 지역이_null이_아니면_성공() { + // given + SchoolRegion region = SchoolRegion.대구; + + // when + school.changeRegion(region); + + // then + assertThat(school.getRegion()).isEqualTo(region); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void logoUrl이_null_또는_공백이면_기본값이_할당된다(String logoUrl) { + // when + school.changeLogoUrl(logoUrl); + + // then + assertThat(school.getLogoUrl()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void logoUrl이_255글자_이내이면_성공(int length) { + String logoUrl = "1".repeat(length); + + // when + school.changeLogoUrl(logoUrl); + + // then + assertThat(school.getLogoUrl()).isEqualTo(logoUrl); + } + + @Test + void logoUrl이_255자를_넘으면_예외() { + // given + String logoUrl = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> school.changeLogoUrl(logoUrl)) + .isInstanceOf(ValidException.class); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void backgroundImageUrl이_null_또는_공백이면_기본값이_할당된다(String backgroundImageUrl) { + // when + school.changeBackgroundImageUrl(backgroundImageUrl); + + // then + assertThat(school.getBackgroundUrl()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(ints = {1, 255}) + void backgroundImageUrl이_255글자_이내이면_성공(int length) { + // given + String backgroundImageUrl = "1".repeat(length); + + // when + school.changeBackgroundImageUrl(backgroundImageUrl); + + // then + assertThat(school.getBackgroundUrl()).isEqualTo(backgroundImageUrl); + } - // when - School school = new School("domain", name); + @Test + void backgroundImageUrl이_255자를_넘으면_예외() { + // given + String backgroundImageUrl = "1".repeat(256); - // then - assertThat(school.getName()).isEqualTo(name); + // when & then + assertThatThrownBy(() -> school.changeBackgroundImageUrl(backgroundImageUrl)) + .isInstanceOf(ValidException.class); + } } } diff --git a/backend/src/test/java/com/festago/school/presentation/v1/SchoolSearchV1ControllerTest.java b/backend/src/test/java/com/festago/school/presentation/v1/SchoolSearchV1ControllerTest.java new file mode 100644 index 000000000..488c2d7dc --- /dev/null +++ b/backend/src/test/java/com/festago/school/presentation/v1/SchoolSearchV1ControllerTest.java @@ -0,0 +1,82 @@ +package com.festago.school.presentation.v1; + +import static org.mockito.BDDMockito.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.school.application.v1.SchoolTotalSearchV1QueryService; +import com.festago.school.dto.v1.SchoolTotalSearchV1Response; +import com.festago.support.CustomWebMvcTest; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolSearchV1ControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + SchoolTotalSearchV1QueryService schoolTotalSearchV1QueryService; + + @Nested + class 학교_상세_조회 { + + final String uri = "/api/v1/search/schools"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_학교_목록이_반환된다() throws Exception { + // given + LocalDate festivalStartDate = LocalDate.now(); + + var response = List.of( + new SchoolTotalSearchV1Response(1L, "테코대학교", "https://image.com/logo1.png", festivalStartDate), + new SchoolTotalSearchV1Response(2L, "우테대학교", "https://image.com/logo2.png", null) + ); + given(schoolTotalSearchV1QueryService.searchSchools(anyString())) + .willReturn(response); + + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .queryParam("keyword", "테코대학교")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.size()").value(2)); + } + + @Test + void 쿼리_파라미터_keyword가_2글자_미만이면_400_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .queryParam("keyword", "1")) + .andExpect(status().isBadRequest()); + } + + @Test + void 쿼리_파라미터_keyword가_blank_이면_400_응답이_반환된다() throws Exception { + // when & then + mockMvc.perform(get(uri) + .contentType(MediaType.APPLICATION_JSON) + .queryParam("keyword", " ")) + .andExpect(status().isBadRequest()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java b/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java new file mode 100644 index 000000000..65b87935a --- /dev/null +++ b/backend/src/test/java/com/festago/school/presentation/v1/SchoolV1ControllerTest.java @@ -0,0 +1,118 @@ +package com.festago.school.presentation.v1; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festago.school.application.v1.SchoolV1QueryService; +import com.festago.school.dto.v1.SchoolDetailV1Response; +import com.festago.school.dto.v1.SchoolFestivalV1Response; +import com.festago.school.dto.v1.SchoolSocialMediaV1Response; +import com.festago.school.repository.v1.SchoolFestivalV1SearchCondition; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.support.CustomWebMvcTest; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@CustomWebMvcTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolV1ControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + SchoolV1QueryService schoolV1QueryService; + + @Nested + class 학교_상세_조회 { + + final String uri = "/api/v1/schools/{schoolId}"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var expected = new SchoolDetailV1Response( + 1L, "경북대학교", + "https://image.com/logo.png", + "https://image.com/backgroundLogo.png", + List.of( + new SchoolSocialMediaV1Response(SocialMediaType.YOUTUBE, "유튜브", + "https://image.com/youtube.png", "www.knu-youtube.com"), + new SchoolSocialMediaV1Response(SocialMediaType.INSTAGRAM, "인스타그램", + "https://image.com/youtube.png", "www.knu-instagram.com") + ) + ); + given(schoolV1QueryService.findDetailById(expected.id())) + .willReturn(expected); + + // when & then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + } + } + + @Nested + class 학교_축제_조회 { + + final String uri = "/api/v1/schools/{schoolId}/festivals"; + + @Nested + @DisplayName("GET " + uri) + class 올바른_주소로 { + + @Test + void 요청을_보내면_200_응답과_body가_반환된다() throws Exception { + // given + var today = LocalDate.now(); + var searchCondition = new SchoolFestivalV1SearchCondition(null, null, false, Pageable.ofSize(10)); + var content = List.of(new SchoolFestivalV1Response( + 1L, "경북대학교", today, today.plusDays(1), "www.image.com/image.png", + "아티스트" + )); + var slice = new SliceImpl<>(content, Pageable.ofSize(10), true); + + given(schoolV1QueryService.findFestivalsBySchoolId(1L, today, searchCondition)) + .willReturn(slice); + + // when & then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void 요청시_페이지가_20을_넘어가면_예외() throws Exception { + // given + int maxPageSize = 20; + + // when && then + mockMvc.perform(get(uri, 1L) + .contentType(MediaType.APPLICATION_JSON) + .param("size", String.valueOf(maxPageSize + 1))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + } + } +} diff --git a/backend/src/test/java/com/festago/school/repository/MemorySchoolRepository.java b/backend/src/test/java/com/festago/school/repository/MemorySchoolRepository.java new file mode 100644 index 000000000..fa638a2a1 --- /dev/null +++ b/backend/src/test/java/com/festago/school/repository/MemorySchoolRepository.java @@ -0,0 +1,27 @@ +package com.festago.school.repository; + +import com.festago.school.domain.School; +import com.festago.support.AbstractMemoryRepository; +import java.util.Optional; + +public class MemorySchoolRepository extends AbstractMemoryRepository implements SchoolRepository { + + @Override + public boolean existsByDomain(String domain) { + return memory.values().stream() + .anyMatch(it -> it.getDomain().equals(domain)); + } + + @Override + public boolean existsByName(String name) { + return memory.values().stream() + .anyMatch(it -> it.getName().equals(name)); + } + + @Override + public Optional findByName(String name) { + return memory.values().stream() + .filter(it -> it.getName().equals(name)) + .findFirst(); + } +} diff --git a/backend/src/test/java/com/festago/school/repository/MemorySchoolRepositoryTest.java b/backend/src/test/java/com/festago/school/repository/MemorySchoolRepositoryTest.java new file mode 100644 index 000000000..7ea98b7fe --- /dev/null +++ b/backend/src/test/java/com/festago/school/repository/MemorySchoolRepositoryTest.java @@ -0,0 +1,54 @@ +package com.festago.school.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.support.fixture.SchoolFixture; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemorySchoolRepositoryTest { + + SchoolRepository schoolRepository; + + @BeforeEach + void setUp() { + schoolRepository = new MemorySchoolRepository(); + } + + @Test + void 학교를_저장한다() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + + // when && then + assertThat(school.getId()).isPositive(); + } + + @Test + void 특정_필드로_조회한다() { + // given + schoolRepository.save(SchoolFixture.builder() + .region(SchoolRegion.서울) + .name("학교이름") + .domain("knu.ac.kr") + .build() + ); + + // when && then + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(schoolRepository.findByName("학교이름")).isNotEmpty(); + softly.assertThat(schoolRepository.findByName("없는학교")).isEmpty(); + softly.assertThat(schoolRepository.existsByName("학교이름")).isTrue(); + softly.assertThat(schoolRepository.existsByName("없는학교")).isFalse(); + softly.assertThat(schoolRepository.existsByDomain("knu.ac.kr")).isTrue(); + softly.assertThat(schoolRepository.existsByDomain("no.ac.kr")).isFalse(); + }); + } +} diff --git a/backend/src/test/java/com/festago/socialmedia/application/SocialMediaCommandServiceTest.java b/backend/src/test/java/com/festago/socialmedia/application/SocialMediaCommandServiceTest.java new file mode 100644 index 000000000..8b6c32e5e --- /dev/null +++ b/backend/src/test/java/com/festago/socialmedia/application/SocialMediaCommandServiceTest.java @@ -0,0 +1,193 @@ +package com.festago.socialmedia.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.festago.artist.repository.ArtistRepository; +import com.festago.artist.repository.MemoryArtistRepository; +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.school.domain.School; +import com.festago.school.repository.MemorySchoolRepository; +import com.festago.school.repository.SchoolRepository; +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.socialmedia.dto.command.SocialMediaCreateCommand; +import com.festago.socialmedia.dto.command.SocialMediaUpdateCommand; +import com.festago.socialmedia.repository.MemorySocialMediaRepository; +import com.festago.socialmedia.repository.SocialMediaRepository; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.SocialMediaFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SocialMediaCommandServiceTest { + + SocialMediaCommandService socialMediaCommandService; + + SocialMediaRepository socialMediaRepository; + + SchoolRepository schoolRepository; + + ArtistRepository artistRepository; + + @BeforeEach + void setUp() { + socialMediaRepository = new MemorySocialMediaRepository(); + schoolRepository = new MemorySchoolRepository(); + artistRepository = new MemoryArtistRepository(); + socialMediaCommandService = new SocialMediaCommandService( + socialMediaRepository, + schoolRepository, + artistRepository + ); + } + + @Nested + class createSocialMedia { + + @Test + void 중복된_소셜미디어가_있으면_예외() { + // given + School 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + + SocialMedia socialMedia = socialMediaRepository.save(SocialMediaFixture.builder() + .ownerId(테코대학교.getId()) + .ownerType(OwnerType.SCHOOL) + .mediaType(SocialMediaType.INSTAGRAM) + .build()); + + // when & then + var command = SocialMediaCreateCommand.builder() + .ownerId(socialMedia.getId()) + .ownerType(socialMedia.getOwnerType()) + .socialMediaType(socialMedia.getMediaType()) + .name(socialMedia.getName()) + .logoUrl(socialMedia.getLogoUrl()) + .url(socialMedia.getUrl()) + .build(); + assertThatThrownBy(() -> socialMediaCommandService.createSocialMedia(command)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.DUPLICATE_SOCIAL_MEDIA.getMessage()); + } + + // TODO 다른 Owner에 대한 테스트를 어떻게 작성할지? ex) MethodSource + @Test + void 추가하려는_소셜미디어의_owner가_존재하지_않으면_예외() { + // when & then + var command = SocialMediaCreateCommand.builder() + .ownerId(4885L) + .ownerType(OwnerType.SCHOOL) + .socialMediaType(SocialMediaType.INSTAGRAM) + .name("테코대학교 인스타그램") + .logoUrl("https://image.com/logo.png") + .url("https://instagram.com/tecodaehak") + .build(); + assertThatThrownBy(() -> socialMediaCommandService.createSocialMedia(command)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.SCHOOL_NOT_FOUND.getMessage()); + } + + @Test + void 성공하면_소셜미디어가_저장된다() { + // given + School 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + + // when + var command = SocialMediaCreateCommand.builder() + .ownerId(테코대학교.getId()) + .ownerType(OwnerType.SCHOOL) + .socialMediaType(SocialMediaType.INSTAGRAM) + .name("테코대학교 인스타그램") + .logoUrl("https://image.com/logo.png") + .url("https://instagram.com/tecodaehak") + .build(); + Long socialMediaId = socialMediaCommandService.createSocialMedia(command); + + // then + assertThat(socialMediaRepository.findById(socialMediaId)).isPresent(); + } + } + + @Nested + class updateSocialMedia { + + @Test + void 소셜미디어의_식별자에_대한_소셜미디어가_존재하지_않으면_예외() { + // when & then + var command = SocialMediaUpdateCommand.builder() + .name("테코대학교 인스타그램") + .url("https://instagram.com/tecodaehak") + .logoUrl("https://image.com/logo.png") + .build(); + assertThatThrownBy(() -> socialMediaCommandService.updateSocialMedia(4885L, command)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.SOCIAL_MEDIA_NOT_FOUND.getMessage()); + } + + @Test + void 성공하면_소셜미디어의_정보가_변경된다() { + // given + School 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + + SocialMedia socialMedia = socialMediaRepository.save(SocialMediaFixture.builder() + .ownerId(테코대학교.getId()) + .ownerType(OwnerType.SCHOOL) + .mediaType(SocialMediaType.INSTAGRAM) + .build()); + + // when + var command = SocialMediaUpdateCommand.builder() + .name("테코대학교 인스타그램") + .url("https://instagram.com/tecodaehak") + .logoUrl("https://image.com/logo.png") + .build(); + socialMediaCommandService.updateSocialMedia(socialMedia.getId(), command); + + // then + SocialMedia actual = socialMediaRepository.getOrThrow(socialMedia.getId()); + assertSoftly(softly -> { + softly.assertThat(actual.getName()).isEqualTo(command.name()); + softly.assertThat(actual.getUrl()).isEqualTo(command.url()); + softly.assertThat(actual.getLogoUrl()).isEqualTo(command.logoUrl()); + }); + } + } + + @Nested + class deleteSocialMedia { + + @Test + void 삭제하려는_소셜미디어가_존재하지_않아도_예외가_발생하지_않는다() { + // when & then + assertDoesNotThrow(() -> socialMediaCommandService.deleteSocialMedia(4885L)); + } + + @Test + void 소셜미디어의_식별자로_삭제할_수_있다() { + // given + School 테코대학교 = schoolRepository.save(SchoolFixture.builder().name("테코대학교").build()); + + SocialMedia socialMedia = socialMediaRepository.save(SocialMediaFixture.builder() + .ownerId(테코대학교.getId()) + .ownerType(OwnerType.SCHOOL) + .mediaType(SocialMediaType.INSTAGRAM) + .build()); + + // when + socialMediaCommandService.deleteSocialMedia(socialMedia.getId()); + + // then + assertThat(socialMediaRepository.findById(socialMedia.getId())).isEmpty(); + } + } +} diff --git a/backend/src/test/java/com/festago/socialmedia/repository/MemorySocialMediaRepository.java b/backend/src/test/java/com/festago/socialmedia/repository/MemorySocialMediaRepository.java new file mode 100644 index 000000000..bfc074735 --- /dev/null +++ b/backend/src/test/java/com/festago/socialmedia/repository/MemorySocialMediaRepository.java @@ -0,0 +1,23 @@ +package com.festago.socialmedia.repository; + +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; +import com.festago.support.AbstractMemoryRepository; +import java.util.Objects; + +public class MemorySocialMediaRepository extends AbstractMemoryRepository implements + SocialMediaRepository { + + @Override + public boolean existsByOwnerIdAndOwnerTypeAndMediaType( + Long ownerId, + OwnerType ownerType, + SocialMediaType mediaType + ) { + return memory.values().stream() + .filter(it -> Objects.equals(it.getOwnerId(), ownerId)) + .filter(it -> it.getOwnerType() == ownerType) + .anyMatch(it -> it.getMediaType() == mediaType); + } +} diff --git a/backend/src/test/java/com/festago/stage/application/StageQueryInfoServiceTest.java b/backend/src/test/java/com/festago/stage/application/StageQueryInfoServiceTest.java new file mode 100644 index 000000000..72e0572dd --- /dev/null +++ b/backend/src/test/java/com/festago/stage/application/StageQueryInfoServiceTest.java @@ -0,0 +1,129 @@ +package com.festago.stage.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.artist.domain.Artist; +import com.festago.artist.domain.ArtistsSerializer; +import com.festago.artist.infrastructure.DelimiterArtistsSerializer; +import com.festago.artist.repository.ArtistRepository; +import com.festago.artist.repository.MemoryArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.NotFoundException; +import com.festago.stage.domain.StageQueryInfo; +import com.festago.stage.repository.MemoryStageArtistRepository; +import com.festago.stage.repository.MemoryStageQueryInfoRepository; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageQueryInfoRepository; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.StageArtistFixture; +import com.festago.support.fixture.StageQueryInfoFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StageQueryInfoServiceTest { + + private final Long stageId = 1L; + + StageQueryInfoService stageQueryInfoService; + + StageQueryInfoRepository stageQueryInfoRepository; + + StageArtistRepository stageArtistRepository; + + ArtistRepository artistRepository; + + ArtistsSerializer artistsSerializer = new DelimiterArtistsSerializer(","); + + Artist 뉴진스; + + @BeforeEach + void setUp() { + stageQueryInfoRepository = new MemoryStageQueryInfoRepository(); + stageArtistRepository = new MemoryStageArtistRepository(); + artistRepository = new MemoryArtistRepository(); + stageQueryInfoService = new StageQueryInfoService( + stageQueryInfoRepository, + stageArtistRepository, + artistRepository, + artistsSerializer + ); + 뉴진스 = artistRepository.save(ArtistFixture.builder().name("뉴진스").build()); + } + + @Nested + class initialStageQueryInfo { + + @Test + void Artist가_존재하지_않으면_예외() { + // given + stageArtistRepository.save(StageArtistFixture.builder(stageId, 4885L).build()); + + // when + assertThatThrownBy(() -> stageQueryInfoService.initialStageQueryInfo(stageId)) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.ARTIST_NOT_FOUND.getMessage()); + } + + @Test + void StageQueryInfo가_생성된다() { + // given + stageArtistRepository.save(StageArtistFixture.builder(stageId, 뉴진스.getId()).build()); + + // when + stageQueryInfoService.initialStageQueryInfo(stageId); + + // then + assertThat(stageQueryInfoRepository.findByStageId(stageId)).isPresent(); + } + } + + @Nested + class renewalStageQueryInfo { + + @Test + void Stage_식별자에_대한_StageQueryInfo가_없으면_예외() { + // when & then + assertThatThrownBy(() -> stageQueryInfoService.renewalStageQueryInfo(stageId)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.STAGE_NOT_FOUND.getMessage()); + } + + @Test + void StageQueryInfo가_새롭게_갱신된다() { + // given + stageQueryInfoRepository.save( + StageQueryInfoFixture.builder().stageId(stageId).artistInfo("oldInfo").build()); + stageArtistRepository.save(StageArtistFixture.builder(stageId, 뉴진스.getId()).build()); + + // when + stageQueryInfoService.renewalStageQueryInfo(stageId); + + // then + StageQueryInfo stageQueryInfo = stageQueryInfoRepository.findByStageId(stageId).get(); + assertThat(stageQueryInfo.getArtistInfo()).isNotEqualTo("oldInfo"); + } + } + + @Nested + class deleteStageQueryInfo { + + @Test + void StageQueryInfo가_삭제된다() { + // given + stageQueryInfoRepository.save(StageQueryInfoFixture.builder().stageId(stageId).build()); + + // when + stageQueryInfoService.deleteStageQueryInfo(stageId); + + // then + assertThat(stageQueryInfoRepository.findByStageId(stageId)).isEmpty(); + } + } +} diff --git a/backend/src/test/java/com/festago/stage/application/command/StageCommandServiceIntegrationTest.java b/backend/src/test/java/com/festago/stage/application/command/StageCommandServiceIntegrationTest.java new file mode 100644 index 000000000..1973b438e --- /dev/null +++ b/backend/src/test/java/com/festago/stage/application/command/StageCommandServiceIntegrationTest.java @@ -0,0 +1,298 @@ +package com.festago.stage.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.festival.domain.FestivalQueryInfo; +import com.festago.festival.repository.FestivalInfoRepository; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.StageQueryInfo; +import com.festago.stage.dto.command.StageCreateCommand; +import com.festago.stage.dto.command.StageUpdateCommand; +import com.festago.stage.repository.StageQueryInfoRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.FestivalQueryInfoFixture; +import com.festago.support.fixture.SchoolFixture; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +public class StageCommandServiceIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + StageCreateService stageCreateService; + + @Autowired + StageUpdateService stageUpdateService; + + @Autowired + StageDeleteService stageDeleteService; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + FestivalInfoRepository festivalInfoRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + ArtistRepository artistRepository; + + @Autowired + StageQueryInfoRepository stageQueryInfoRepository; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + Clock clock; + + LocalDateTime now = LocalDateTime.parse("2077-06-29T18:00:00"); + LocalDate festivalStartDate = LocalDate.parse("2077-06-30"); + LocalDate festivalEndDate = LocalDate.parse("2077-07-02"); + Long 테코대학교_식별자; + Long 테코대학교_축제_식별자; + Long 에픽하이_식별자; + Long 소녀시대_식별자; + Long 뉴진스_식별자; + + @BeforeEach + void setUp() { + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + School 테코대학교 = schoolRepository.save(SchoolFixture.builder() + .name("테코대학교") + .region(SchoolRegion.서울) + .build()); + 테코대학교_식별자 = 테코대학교.getId(); + 테코대학교_축제_식별자 = festivalRepository.save(FestivalFixture.builder() + .name("테코대학교 축제") + .startDate(festivalStartDate) + .endDate(festivalEndDate) + .school(테코대학교) + .build()).getId(); + festivalInfoRepository.save(FestivalQueryInfoFixture.builder().festivalId(테코대학교_축제_식별자).build()); + + 에픽하이_식별자 = artistRepository.save(ArtistFixture.builder().name("에픽하이").build()).getId(); + 소녀시대_식별자 = artistRepository.save(ArtistFixture.builder().name("소녀시대").build()).getId(); + 뉴진스_식별자 = artistRepository.save(ArtistFixture.builder().name("뉴진스").build()).getId(); + } + + @Nested + class createStage { + + @Test + void 공연을_생성하면_StageQueryInfo가_저장된다() { + // given + var command = StageCreateCommand.builder() + .festivalId(테코대학교_축제_식별자) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이_식별자, 소녀시대_식별자, 뉴진스_식별자)) + .build(); + + // when + Long stageId = stageCreateService.createStage(command); + + // then + assertThat(stageQueryInfoRepository.findByStageId(stageId)).isPresent(); + } + + @Test + void 공연을_생성하면_FestivalQueryInfo가_갱신된다() { + // given + var command = StageCreateCommand.builder() + .festivalId(테코대학교_축제_식별자) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이_식별자, 소녀시대_식별자, 뉴진스_식별자)) + .build(); + + // when + FestivalQueryInfo previosFestivalQueryInfo = festivalInfoRepository.findByFestivalId(테코대학교_축제_식별자).get(); + Long stageId = stageCreateService.createStage(command); + + // then + FestivalQueryInfo festivalQueryInfo = festivalInfoRepository.findByFestivalId(테코대학교_축제_식별자).get(); + StageQueryInfo stageQueryInfo = stageQueryInfoRepository.findByStageId(stageId).get(); + + assertThat(festivalQueryInfo.getArtistInfo()).isNotEqualTo(previosFestivalQueryInfo.getArtistInfo()); + assertThat(festivalQueryInfo.getArtistInfo()).isEqualTo(stageQueryInfo.getArtistInfo()); + } + + @Test + void 공연이_여러_개_추가되면_FestivalQueryInfo에_추가된_공연의_ArtistInfo가_갱신된다() throws Exception { + // given + var firstCommand = StageCreateCommand.builder() + .festivalId(테코대학교_축제_식별자) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이_식별자)) + .build(); + var secondCommand = StageCreateCommand.builder() + .festivalId(테코대학교_축제_식별자) + .startTime(festivalStartDate.plusDays(1).atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(소녀시대_식별자)) + .build(); + + // when + stageCreateService.createStage(firstCommand); + stageCreateService.createStage(secondCommand); + + // then + FestivalQueryInfo festivalQueryInfo = festivalInfoRepository.findByFestivalId(테코대학교_축제_식별자).get(); + List actual = Arrays.asList( + objectMapper.readValue(festivalQueryInfo.getArtistInfo(), Artist[].class) + ); + assertThat(actual) + .map(Artist::getId) + .containsExactlyInAnyOrder(에픽하이_식별자, 소녀시대_식별자); + } + + @Test + void 공연이_여러_개_추가될때_공연에_중복된_아티스트가_있어도_FestivalQueryInfo에는_중복이_없다() throws Exception { + // given + var firstCommand = StageCreateCommand.builder() + .festivalId(테코대학교_축제_식별자) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이_식별자, 소녀시대_식별자, 뉴진스_식별자)) + .build(); + var secondCommand = StageCreateCommand.builder() + .festivalId(테코대학교_축제_식별자) + .startTime(festivalStartDate.plusDays(1).atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이_식별자, 소녀시대_식별자, 뉴진스_식별자)) + .build(); + + // when + stageCreateService.createStage(firstCommand); + stageCreateService.createStage(secondCommand); + + // then + FestivalQueryInfo festivalQueryInfo = festivalInfoRepository.findByFestivalId(테코대학교_축제_식별자).get(); + List actual = Arrays.asList( + objectMapper.readValue(festivalQueryInfo.getArtistInfo(), Artist[].class) + ); + assertThat(actual) + .map(Artist::getId) + .containsExactlyInAnyOrder(에픽하이_식별자, 소녀시대_식별자, 뉴진스_식별자); + } + } + + @Nested + class updateStage { + + Long 테코대학교_축제_공연_식별자; + + @BeforeEach + void setUp() { + 테코대학교_축제_공연_식별자 = stageCreateService.createStage(StageCreateCommand.builder() + .festivalId(테코대학교_축제_식별자) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이_식별자, 소녀시대_식별자, 뉴진스_식별자)) + .build()); + } + + @Test + void 공연을_수정하면_StageQueryInfo가_갱신된다() throws Exception { + // given + var command = StageUpdateCommand.builder() + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이_식별자, 소녀시대_식별자)) + .build(); + + // when + stageUpdateService.updateStage(테코대학교_축제_공연_식별자, command); + + // then + StageQueryInfo stageQueryInfo = stageQueryInfoRepository.findByStageId(테코대학교_축제_공연_식별자).get(); + List actual = Arrays.asList( + objectMapper.readValue(stageQueryInfo.getArtistInfo(), Artist[].class) + ); + assertThat(actual) + .map(Artist::getId) + .containsExactlyInAnyOrder(에픽하이_식별자, 소녀시대_식별자); + } + + @Test + void 공연을_수정하면_FestivalQueryInfo가_갱신된다() throws Exception { + // given + var command = StageUpdateCommand.builder() + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이_식별자, 소녀시대_식별자)) + .build(); + + // when + stageUpdateService.updateStage(테코대학교_축제_공연_식별자, command); + + FestivalQueryInfo festivalQueryInfo = festivalInfoRepository.findByFestivalId(테코대학교_축제_식별자).get(); + List actual = Arrays.asList( + objectMapper.readValue(festivalQueryInfo.getArtistInfo(), Artist[].class) + ); + assertThat(actual) + .map(Artist::getId) + .containsExactlyInAnyOrder(에픽하이_식별자, 소녀시대_식별자); + } + } + + @Nested + class deleteStage { + + Long 테코대학교_축제_공연_식별자; + + @BeforeEach + void setUp() { + 테코대학교_축제_공연_식별자 = stageCreateService.createStage(StageCreateCommand.builder() + .festivalId(테코대학교_축제_식별자) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이_식별자, 소녀시대_식별자, 뉴진스_식별자)) + .build()); + } + + @Test + void 공연을_삭제하면_StageQueryInfo가_삭제된다() { + // when + stageDeleteService.deleteStage(테코대학교_축제_공연_식별자); + + // then + assertThat(stageQueryInfoRepository.findByStageId(테코대학교_축제_공연_식별자)).isEmpty(); + } + + @Test + void 공연을_삭제하면_FestivalQueryInfo가_갱신된다() { + // when + stageDeleteService.deleteStage(테코대학교_축제_공연_식별자); + + // then + FestivalQueryInfo festivalQueryInfo = festivalInfoRepository.findByFestivalId(테코대학교_축제_식별자).get(); + assertThat(festivalQueryInfo.getArtistInfo()).isEqualTo("[]"); + } + } +} diff --git a/backend/src/test/java/com/festago/stage/application/command/StageCreateServiceTest.java b/backend/src/test/java/com/festago/stage/application/command/StageCreateServiceTest.java new file mode 100644 index 000000000..e49fa58ff --- /dev/null +++ b/backend/src/test/java/com/festago/stage/application/command/StageCreateServiceTest.java @@ -0,0 +1,192 @@ +package com.festago.stage.application.command; + +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.mockito.BDDMockito.mock; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.artist.repository.MemoryArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.exception.ValidException; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.festival.repository.MemoryFestivalRepository; +import com.festago.stage.dto.command.StageCreateCommand; +import com.festago.stage.repository.MemoryStageArtistRepository; +import com.festago.stage.repository.MemoryStageRepository; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalFixture; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.LongStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StageCreateServiceTest { + + StageRepository stageRepository; + + FestivalRepository festivalRepository; + + ArtistRepository artistRepository; + + StageArtistRepository stageArtistRepository; + + StageCreateService stageCreateService; + + LocalDate festivalStartDate = LocalDate.parse("2077-06-30"); + LocalDate festivalEndDate = LocalDate.parse("2077-07-02"); + + Festival 테코대학교_축제; + + Artist 에픽하이; + Artist 소녀시대; + Artist 뉴진스; + + @BeforeEach + void setUp() { + stageRepository = new MemoryStageRepository(); + festivalRepository = new MemoryFestivalRepository(); + artistRepository = new MemoryArtistRepository(); + stageArtistRepository = new MemoryStageArtistRepository(); + stageCreateService = new StageCreateService( + stageRepository, + festivalRepository, + artistRepository, + stageArtistRepository, + mock() + ); + + 테코대학교_축제 = festivalRepository.save( + FestivalFixture.builder() + .name("테코대학교 축제") + .startDate(festivalStartDate) + .endDate(festivalEndDate) + .build() + ); + + 에픽하이 = artistRepository.save(ArtistFixture.builder().name("에픽하이").build()); + 소녀시대 = artistRepository.save(ArtistFixture.builder().name("소녀시대").build()); + 뉴진스 = artistRepository.save(ArtistFixture.builder().name("뉴진스").build()); + } + + @Nested + class createStage { + + @Test + void ArtistIds에_중복이_있으면_예외() { + // given + var command = StageCreateCommand.builder() + .festivalId(테코대학교_축제.getId()) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이.getId(), 에픽하이.getId())) + .build(); + + // then + // when & then + assertThatThrownBy(() -> stageCreateService.createStage(command)) + .isInstanceOf(ValidException.class) + .hasMessage("artistIds에 중복된 값이 있습니다."); + } + + @Test + void ArtistIds의_개수가_10개를_초과하면_예외() { + // given + List artistIds = LongStream.rangeClosed(1, 11) + .boxed() + .toList(); + var command = StageCreateCommand.builder() + .festivalId(테코대학교_축제.getId()) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(artistIds) + .build(); + + // when & then + assertThatThrownBy(() -> stageCreateService.createStage(command)) + .isInstanceOf(ValidException.class) + .hasMessage("artistIds의 size는 10 이하여야 합니다."); + } + + @Test + void ArtistIds의_개수가_10개_이하이면_예외가_발생하지_않는다() { + // given + List artistIds = LongStream.rangeClosed(1, 10) + .mapToObj(it -> artistRepository.save(ArtistFixture.builder().build())) + .map(Artist::getId) + .toList(); + var command = StageCreateCommand.builder() + .festivalId(테코대학교_축제.getId()) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(artistIds) + .build(); + + // when + assertThatNoException().isThrownBy(() -> stageCreateService.createStage(command)); + } + + @Test + void Festival_식별자에_대한_Festival이_없으면_예외() { + // given + var command = StageCreateCommand.builder() + .festivalId(4885L) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이.getId(), 소녀시대.getId(), 뉴진스.getId())) + .build(); + + // when & then + assertThatThrownBy(() -> stageCreateService.createStage(command)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.FESTIVAL_NOT_FOUND.getMessage()); + } + + @Test + void 아티스트_식별자_목록에_존재하지_않은_아티스트가_있으면_예외() { + // given + var command = StageCreateCommand.builder() + .festivalId(테코대학교_축제.getId()) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이.getId(), 소녀시대.getId(), 뉴진스.getId(), 4885L)) + .build(); + + // when & then + assertThatThrownBy(() -> stageCreateService.createStage(command)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.ARTIST_NOT_FOUND.getMessage()); + } + + @Test + void 성공하면_생성한_Stage에_대한_StageArtist가_저장된다() { + // given + var command = StageCreateCommand.builder() + .festivalId(테코대학교_축제.getId()) + .startTime(festivalStartDate.atTime(18, 0)) + .ticketOpenTime(festivalStartDate.minusWeeks(1).atStartOfDay()) + .artistIds(List.of(에픽하이.getId(), 소녀시대.getId(), 뉴진스.getId())) + .build(); + + // when + Long stageId = stageCreateService.createStage(command); + + // then + Set stageArtists = stageArtistRepository.findAllArtistIdByStageId(stageId); + assertThat(stageArtists) + .containsExactlyInAnyOrder(에픽하이.getId(), 소녀시대.getId(), 뉴진스.getId()); + } + } +} diff --git a/backend/src/test/java/com/festago/stage/application/command/StageDeleteServiceTest.java b/backend/src/test/java/com/festago/stage/application/command/StageDeleteServiceTest.java new file mode 100644 index 000000000..d81070c4d --- /dev/null +++ b/backend/src/test/java/com/festago/stage/application/command/StageDeleteServiceTest.java @@ -0,0 +1,109 @@ +package com.festago.stage.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.artist.repository.MemoryArtistRepository; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.festival.repository.MemoryFestivalRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.MemoryStageArtistRepository; +import com.festago.stage.repository.MemoryStageRepository; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.StageArtistFixture; +import com.festago.support.fixture.StageFixture; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StageDeleteServiceTest { + + ArtistRepository artistRepository; + FestivalRepository festivalRepository; + StageRepository stageRepository; + StageArtistRepository stageArtistRepository; + StageDeleteService stageDeleteService; + + LocalDateTime stageStartTime = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime ticketOpenTime = stageStartTime.minusWeeks(1); + Festival 테코대학교_축제; + Stage 테코대학교_축제_공연; + Artist 에픽하이; + Artist 소녀시대; + Artist 뉴진스; + + @BeforeEach + void setUp() { + artistRepository = new MemoryArtistRepository(); + festivalRepository = new MemoryFestivalRepository(); + stageRepository = new MemoryStageRepository(); + stageArtistRepository = new MemoryStageArtistRepository(); + stageDeleteService = new StageDeleteService(stageRepository, stageArtistRepository, mock()); + + 테코대학교_축제 = festivalRepository.save( + FestivalFixture.builder() + .name("테코대학교 축제") + .startDate(stageStartTime.toLocalDate()) + .endDate(stageStartTime.toLocalDate().plusDays(2)) + .build() + ); + 테코대학교_축제_공연 = stageRepository.save( + StageFixture.builder() + .festival(테코대학교_축제) + .startTime(stageStartTime) + .ticketOpenTime(ticketOpenTime) + .build() + ); + 에픽하이 = artistRepository.save(ArtistFixture.builder().name("에픽하이").build()); + 소녀시대 = artistRepository.save(ArtistFixture.builder().name("소녀시대").build()); + 뉴진스 = artistRepository.save(ArtistFixture.builder().name("뉴진스").build()); + stageArtistRepository.save(StageArtistFixture.builder(테코대학교_축제_공연.getId(), 에픽하이.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(테코대학교_축제_공연.getId(), 소녀시대.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(테코대학교_축제_공연.getId(), 뉴진스.getId()).build()); + } + + @Nested + class deleteStage { + + @Test + void 삭제하려는_공연의_식별자가_존재하지_않아도_예외가_발생하지_않는다() { + // given + Long stageId = 4885L; + + // when + assertThatNoException() + .isThrownBy(() -> stageDeleteService.deleteStage(stageId)); + } + + @Test + void 성공하면_저장된_Stage가_삭제된다() { + // when + stageDeleteService.deleteStage(테코대학교_축제_공연.getId()); + + // then + assertThat(stageRepository.findById(테코대학교_축제_공연.getId())).isEmpty(); + } + + @Test + void 성공하면_식별자에_대한_StageArtist가_삭제된다() { + // when + stageDeleteService.deleteStage(테코대학교_축제_공연.getId()); + + // then + assertThat(stageArtistRepository.findAllArtistIdByStageId(테코대학교_축제_공연.getId())).isEmpty(); + } + } +} diff --git a/backend/src/test/java/com/festago/stage/application/command/StageUpdateServiceTest.java b/backend/src/test/java/com/festago/stage/application/command/StageUpdateServiceTest.java new file mode 100644 index 000000000..cf5e173ec --- /dev/null +++ b/backend/src/test/java/com/festago/stage/application/command/StageUpdateServiceTest.java @@ -0,0 +1,201 @@ +package com.festago.stage.application.command; + +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.mockito.Mockito.mock; + +import com.festago.artist.domain.Artist; +import com.festago.artist.repository.ArtistRepository; +import com.festago.artist.repository.MemoryArtistRepository; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.common.exception.ValidException; +import com.festago.festival.domain.Festival; +import com.festago.stage.domain.Stage; +import com.festago.stage.dto.command.StageUpdateCommand; +import com.festago.stage.repository.MemoryStageArtistRepository; +import com.festago.stage.repository.MemoryStageRepository; +import com.festago.stage.repository.StageArtistRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.fixture.ArtistFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.StageArtistFixture; +import com.festago.support.fixture.StageFixture; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.LongStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StageUpdateServiceTest { + + StageRepository stageRepository; + ArtistRepository artistRepository; + StageArtistRepository stageArtistRepository; + StageUpdateService stageUpdateService; + + LocalDateTime stageStartTime = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime ticketOpenTime = stageStartTime.minusWeeks(1); + Festival 테코대학교_축제; + Stage 테코대학교_축제_공연; + Artist 에픽하이; + Artist 소녀시대; + Artist 뉴진스; + + @BeforeEach + void setUp() { + stageRepository = new MemoryStageRepository(); + artistRepository = new MemoryArtistRepository(); + stageArtistRepository = new MemoryStageArtistRepository(); + stageUpdateService = new StageUpdateService(stageRepository, artistRepository, stageArtistRepository, mock()); + + 테코대학교_축제 = FestivalFixture.builder() + .name("테코대학교 축제") + .startDate(stageStartTime.toLocalDate()) + .endDate(stageStartTime.toLocalDate().plusDays(2)) + .build(); + 테코대학교_축제_공연 = stageRepository.save(StageFixture.builder() + .festival(테코대학교_축제) + .startTime(stageStartTime) + .ticketOpenTime(ticketOpenTime) + .build()); + + 에픽하이 = artistRepository.save(ArtistFixture.builder().name("에픽하이").build()); + 소녀시대 = artistRepository.save(ArtistFixture.builder().name("소녀시대").build()); + 뉴진스 = artistRepository.save(ArtistFixture.builder().name("뉴진스").build()); + + stageArtistRepository.save(StageArtistFixture.builder(테코대학교_축제_공연.getId(), 에픽하이.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(테코대학교_축제_공연.getId(), 소녀시대.getId()).build()); + stageArtistRepository.save(StageArtistFixture.builder(테코대학교_축제_공연.getId(), 뉴진스.getId()).build()); + } + + @Nested + class updateStage { + + @Test + void ArtistIds에_중복이_있으면_예외() { + // given + var command = StageUpdateCommand.builder() + .startTime(stageStartTime.minusHours(1)) + .ticketOpenTime(ticketOpenTime.minusDays(1)) + .artistIds(List.of(에픽하이.getId(), 에픽하이.getId())) + .build(); + + // then + // when & then + assertThatThrownBy(() -> stageUpdateService.updateStage(테코대학교_축제_공연.getId(), command)) + .isInstanceOf(ValidException.class) + .hasMessage("artistIds에 중복된 값이 있습니다."); + } + + @Test + void ArtistIds의_개수가_10개를_초과하면_예외() { + // given + List artistIds = LongStream.rangeClosed(1, 11) + .boxed() + .toList(); + var command = StageUpdateCommand.builder() + .startTime(stageStartTime.minusHours(1)) + .ticketOpenTime(ticketOpenTime.minusDays(1)) + .artistIds(artistIds) + .build(); + + // when & then + assertThatThrownBy(() -> stageUpdateService.updateStage(테코대학교_축제_공연.getId(), command)) + .isInstanceOf(ValidException.class) + .hasMessage("artistIds의 size는 10 이하여야 합니다."); + } + + @Test + void ArtistIds의_개수가_10개_이하이면_예외가_발생하지_않는다() { + // given + List artistIds = LongStream.rangeClosed(1, 10) + .mapToObj(it -> artistRepository.save(ArtistFixture.builder().build())) + .map(Artist::getId) + .toList(); + var command = StageUpdateCommand.builder() + .startTime(stageStartTime.minusHours(1)) + .ticketOpenTime(ticketOpenTime.minusDays(1)) + .artistIds(artistIds) + .build(); + + // when + assertThatNoException() + .isThrownBy(() -> stageUpdateService.updateStage(테코대학교_축제_공연.getId(), command)); + } + + @Test + void Stage에_대한_식별자가_없으면_예외() { + // given + Long stageId = 4885L; + var command = StageUpdateCommand.builder() + .startTime(stageStartTime.minusHours(1)) + .ticketOpenTime(ticketOpenTime.minusDays(1)) + .artistIds(List.of(에픽하이.getId(), 소녀시대.getId(), 뉴진스.getId())) + .build(); + + // when & then + assertThatThrownBy(() -> stageUpdateService.updateStage(stageId, command)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.STAGE_NOT_FOUND.getMessage()); + } + + @Test + void 아티스트_식별자_목록에_존재하지_않은_아티스트가_있으면_예외() { + // given + var command = StageUpdateCommand.builder() + .startTime(stageStartTime.minusHours(1)) + .ticketOpenTime(ticketOpenTime.minusDays(1)) + .artistIds(List.of(에픽하이.getId(), 소녀시대.getId(), 뉴진스.getId(), 4885L)) + .build(); + + // when & then + assertThatThrownBy(() -> stageUpdateService.updateStage(테코대학교_축제_공연.getId(), command)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.ARTIST_NOT_FOUND.getMessage()); + } + + @Test + void 성공하면_수정된_Stage에_반영된다() { + // given + var command = StageUpdateCommand.builder() + .startTime(stageStartTime.minusHours(1)) + .ticketOpenTime(ticketOpenTime.minusDays(1)) + .artistIds(List.of(에픽하이.getId(), 소녀시대.getId(), 뉴진스.getId())) + .build(); + + // when + stageUpdateService.updateStage(테코대학교_축제_공연.getId(), command); + + // then + Stage actual = stageRepository.findById(테코대학교_축제_공연.getId()).get(); + assertThat(actual.getStartTime()).isEqualTo(command.startTime()); + assertThat(actual.getTicketOpenTime()).isEqualTo(command.ticketOpenTime()); + } + + @Test + void 성공하면_수정된_Stage에_대한_StageArtist가_갱신된다() { + // given + var command = StageUpdateCommand.builder() + .startTime(stageStartTime.minusHours(1)) + .ticketOpenTime(ticketOpenTime.minusDays(1)) + .artistIds(List.of(에픽하이.getId())) + .build(); + + // when + stageUpdateService.updateStage(테코대학교_축제_공연.getId(), command); + + // then + Set artistIds = stageArtistRepository.findAllArtistIdByStageId(테코대학교_축제_공연.getId()); + assertThat(artistIds) + .containsExactly(에픽하이.getId()); + } + } +} diff --git a/backend/src/test/java/com/festago/domain/StageTest.java b/backend/src/test/java/com/festago/stage/domain/StageTest.java similarity index 83% rename from backend/src/test/java/com/festago/domain/StageTest.java rename to backend/src/test/java/com/festago/stage/domain/StageTest.java index b73afd416..fca2cca25 100644 --- a/backend/src/test/java/com/festago/domain/StageTest.java +++ b/backend/src/test/java/com/festago/stage/domain/StageTest.java @@ -1,4 +1,4 @@ -package com.festago.domain; +package com.festago.stage.domain; import static com.festago.common.exception.ErrorCode.INVALID_STAGE_START_TIME; import static com.festago.common.exception.ErrorCode.INVALID_TICKET_OPEN_TIME; @@ -7,8 +7,8 @@ import com.festago.common.exception.BadRequestException; import com.festago.festival.domain.Festival; -import com.festago.support.FestivalFixture; -import com.festago.support.StageFixture; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.StageFixture; import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; @@ -25,13 +25,13 @@ class StageTest { // given LocalDateTime startTime = LocalDateTime.parse("2023-07-26T18:00:00"); LocalDateTime ticketOpenTime = LocalDateTime.parse("2023-07-26T17:00:00"); - Festival festival = FestivalFixture.festival() + Festival festival = FestivalFixture.builder() .startDate(startTime.plusDays(1).toLocalDate()) .endDate(startTime.plusDays(1).toLocalDate()) .build(); // when & then - assertThatThrownBy(() -> StageFixture.stage() + assertThatThrownBy(() -> StageFixture.builder() .startTime(startTime) .ticketOpenTime(ticketOpenTime) .festival(festival) @@ -46,13 +46,13 @@ class StageTest { // given LocalDateTime startTime = LocalDateTime.parse("2023-07-26T18:00:00"); LocalDateTime ticketOpenTime = LocalDateTime.parse("2023-07-26T17:00:00"); - Festival festival = FestivalFixture.festival() + Festival festival = FestivalFixture.builder() .startDate(startTime.minusDays(1).toLocalDate()) .endDate(startTime.minusDays(1).toLocalDate()) .build(); // when & then - assertThatThrownBy(() -> StageFixture.stage() + assertThatThrownBy(() -> StageFixture.builder() .startTime(startTime) .ticketOpenTime(ticketOpenTime) .festival(festival) @@ -67,19 +67,19 @@ class StageTest { void 티켓_오픈_시간이_무대_시작시간과_같거나_이후이면_예외(LocalDateTime ticketOpenTime) { // given LocalDateTime startTime = LocalDateTime.parse("2023-07-26T18:00:00"); - Festival festival = FestivalFixture.festival() + Festival festival = FestivalFixture.builder() .startDate(startTime.toLocalDate()) .endDate(startTime.toLocalDate()) .build(); // when & then - assertThatThrownBy(() -> StageFixture.stage() + assertThatThrownBy(() -> StageFixture.builder() .startTime(startTime) .ticketOpenTime(ticketOpenTime) .festival(festival) .build() ) - .isInstanceOf(IllegalArgumentException.class) + .isInstanceOf(BadRequestException.class) .hasMessage(INVALID_TICKET_OPEN_TIME.getMessage()); } @@ -88,17 +88,16 @@ class StageTest { // given LocalDateTime startTime = LocalDateTime.parse("2023-07-26T18:00:00"); LocalDateTime ticketOpenTime = LocalDateTime.parse("2023-07-26T17:00:00"); - Festival festival = FestivalFixture.festival() + Festival festival = FestivalFixture.builder() .startDate(startTime.toLocalDate()) .endDate(startTime.toLocalDate()) .build(); // when & then - assertThatNoException().isThrownBy(() -> StageFixture.stage() + assertThatNoException().isThrownBy(() -> StageFixture.builder() .startTime(startTime) .ticketOpenTime(ticketOpenTime) .festival(festival) .build()); } - } diff --git a/backend/src/test/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidatorTest.java b/backend/src/test/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidatorTest.java new file mode 100644 index 000000000..043a39730 --- /dev/null +++ b/backend/src/test/java/com/festago/stage/domain/validator/festival/OutOfDateStageFestivalUpdateValidatorTest.java @@ -0,0 +1,136 @@ +package com.festago.stage.domain.validator.festival; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalDuration; +import com.festago.stage.repository.MemoryStageRepository; +import com.festago.stage.repository.StageRepository; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.StageFixture; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class OutOfDateStageFestivalUpdateValidatorTest { + + LocalDate festivalStartDate = LocalDate.parse("2077-02-19"); + LocalDate festivalEndDate = LocalDate.parse("2077-02-21"); + StageRepository stageRepository; + OutOfDateStageFestivalUpdateValidator validator; + Festival 축제; + + @BeforeEach + void setUp() { + stageRepository = new MemoryStageRepository(); + validator = new OutOfDateStageFestivalUpdateValidator(stageRepository); + + 축제 = FestivalFixture.builder() + .startDate(festivalStartDate) + .endDate(festivalEndDate) + .build(); + } + + @Nested + class 축제에_등록된_공연이_있을때 { + + @BeforeEach + void setUp() { + LocalDateTime ticketOpenTime = festivalStartDate.atStartOfDay().minusWeeks(1); + // 19, 20, 21 일자의 공연 생성 + for (int i = 0; i <= 2; i++) { + stageRepository.save( + StageFixture.builder() + .festival(축제) + .ticketOpenTime(ticketOpenTime) + .startTime(festivalStartDate.plusDays(i).atTime(18, 0)) + .build() + ); + } + } + + @Test + void 축제의_일자를_확장하면_예외가_발생하지_않는다() { + // given + LocalDate startDate = festivalStartDate.minusDays(1); + LocalDate endDate = festivalEndDate.plusDays(1); + + 축제.changeFestivalDuration(new FestivalDuration(startDate, endDate)); + + // when & then + assertDoesNotThrow(() -> validator.validate(축제)); + } + + @Test + void 축제의_시작일자를_축소하면_예외가_발생한다() { + // given + LocalDate startDate = festivalStartDate.plusDays(1); + LocalDate endDate = festivalEndDate; + 축제.changeFestivalDuration(new FestivalDuration(startDate, endDate)); + + // when & then + assertThatThrownBy(() -> validator.validate(축제)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.FESTIVAL_UPDATE_OUT_OF_DATE_STAGE_START_TIME.getMessage()); + } + + @Test + void 축제의_종료일자를_축소하면_예외가_발생한다() { + // given + LocalDate startDate = festivalStartDate; + LocalDate endDate = festivalEndDate.minusDays(1); + 축제.changeFestivalDuration(new FestivalDuration(startDate, endDate)); + + // when & then + assertThatThrownBy(() -> validator.validate(축제)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.FESTIVAL_UPDATE_OUT_OF_DATE_STAGE_START_TIME.getMessage()); + } + } + + @Nested + class 축제에_등록된_공연이_없을때 { + + @Test + void 축제의_일자를_확장하면_예외가_발생하지_않는다() { + // given + LocalDate startDate = festivalStartDate.minusDays(1); + LocalDate endDate = festivalEndDate.plusDays(1); + 축제.changeFestivalDuration(new FestivalDuration(startDate, endDate)); + + // when & then + assertDoesNotThrow(() -> validator.validate(축제)); + } + + @Test + void 축제의_시작일자를_축소하면_예외가_발생하지_않는다() { + // given + LocalDate startDate = festivalStartDate.plusDays(1); + LocalDate endDate = festivalEndDate; + 축제.changeFestivalDuration(new FestivalDuration(startDate, endDate)); + + // when & then + assertDoesNotThrow(() -> validator.validate(축제)); + } + + @Test + void 축제의_종료일자를_축소하면_예외가_발생하지_않는다() { + // given + LocalDate startDate = festivalStartDate; + LocalDate endDate = festivalEndDate.minusDays(1); + 축제.changeFestivalDuration(new FestivalDuration(startDate, endDate)); + + // when & then + assertDoesNotThrow(() -> validator.validate(축제)); + } + } +} diff --git a/backend/src/test/java/com/festago/stage/repository/MemoryStageArtistRepository.java b/backend/src/test/java/com/festago/stage/repository/MemoryStageArtistRepository.java new file mode 100644 index 000000000..3ca77f184 --- /dev/null +++ b/backend/src/test/java/com/festago/stage/repository/MemoryStageArtistRepository.java @@ -0,0 +1,33 @@ +package com.festago.stage.repository; + +import static java.util.stream.Collectors.toUnmodifiableSet; + +import com.festago.stage.domain.StageArtist; +import com.festago.support.AbstractMemoryRepository; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public class MemoryStageArtistRepository extends AbstractMemoryRepository implements StageArtistRepository { + + @Override + public Set findAllArtistIdByStageId(Long stageId) { + return memory.values().stream() + .filter(stageArtist -> Objects.equals(stageArtist.getStageId(), stageId)) + .map(StageArtist::getArtistId) + .collect(toUnmodifiableSet()); + } + + @Override + public Set findAllArtistIdByStageIdIn(List stageIds) { + return memory.values().stream() + .filter(stageArtist -> stageIds.contains(stageArtist.getStageId())) + .map(StageArtist::getArtistId) + .collect(toUnmodifiableSet()); + } + + @Override + public void deleteByStageId(Long stageId) { + memory.entrySet().removeIf(entry -> Objects.equals(entry.getValue().getStageId(), stageId)); + } +} diff --git a/backend/src/test/java/com/festago/stage/repository/MemoryStageQueryInfoRepository.java b/backend/src/test/java/com/festago/stage/repository/MemoryStageQueryInfoRepository.java new file mode 100644 index 000000000..ce7fa4e46 --- /dev/null +++ b/backend/src/test/java/com/festago/stage/repository/MemoryStageQueryInfoRepository.java @@ -0,0 +1,24 @@ +package com.festago.stage.repository; + +import com.festago.stage.domain.StageQueryInfo; +import com.festago.support.AbstractMemoryRepository; +import java.util.Objects; +import java.util.Optional; + +public class MemoryStageQueryInfoRepository extends AbstractMemoryRepository implements + StageQueryInfoRepository { + + @Override + public Optional findByStageId(Long stageId) { + return memory.values() + .stream() + .filter(stageQueryInfo -> Objects.equals(stageQueryInfo.getStageId(), stageId)) + .findAny(); + } + + @Override + public void deleteByStageId(Long stageId) { + memory.entrySet() + .removeIf(entry -> Objects.equals(entry.getValue().getStageId(), stageId)); + } +} diff --git a/backend/src/test/java/com/festago/stage/repository/MemoryStageRepository.java b/backend/src/test/java/com/festago/stage/repository/MemoryStageRepository.java new file mode 100644 index 000000000..66067201e --- /dev/null +++ b/backend/src/test/java/com/festago/stage/repository/MemoryStageRepository.java @@ -0,0 +1,39 @@ +package com.festago.stage.repository; + +import com.festago.stage.domain.Stage; +import com.festago.support.AbstractMemoryRepository; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class MemoryStageRepository extends AbstractMemoryRepository implements StageRepository { + + + @Override + public void flush() { + //NOOP + } + + @Override + public boolean existsByFestivalId(Long festivalId) { + return memory.values().stream() + .anyMatch(stage -> Objects.equals(stage.getFestival().getId(), festivalId)); + } + + @Override + public List findAllByFestivalId(Long festivalId) { + return memory.values().stream() + .filter(stage -> Objects.equals(stage.getFestival().getId(), festivalId)) + .toList(); + } + + @Override + public List findAllDetailByFestivalId(Long festivalId) { + return findAllByFestivalId(festivalId); + } + + @Override + public Optional findByIdWithFetch(Long id) { + return findById(id); + } +} diff --git a/backend/src/test/java/com/festago/stage/repository/StageRepositoryTest.java b/backend/src/test/java/com/festago/stage/repository/StageRepositoryTest.java new file mode 100644 index 000000000..6419a7b75 --- /dev/null +++ b/backend/src/test/java/com/festago/stage/repository/StageRepositoryTest.java @@ -0,0 +1,112 @@ +package com.festago.stage.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.support.RepositoryTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.TicketFixture; +import com.festago.ticket.domain.Ticket; +import com.festago.ticket.domain.TicketType; +import com.festago.ticket.repository.TicketRepository; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@RepositoryTest +class StageRepositoryTest { + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + TicketRepository ticketRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + EntityManager entityManager; + + @Test + void 티켓이_존재하지_않을때도_무대가_조회된다() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + Stage stage1 = stageRepository.save(StageFixture.builder().festival(festival).build()); + Stage stage2 = stageRepository.save(StageFixture.builder().festival(festival).build()); + + // when + List actual = stageRepository.findAllDetailByFestivalId(festival.getId()); + + // then + List stageIds = actual.stream() + .map(Stage::getId) + .toList(); + assertThat(stageIds).containsExactlyInAnyOrder(stage1.getId(), stage2.getId()); + } + + @Test + void 해당_축제의_무대가_모두_조회된다() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + stageRepository.save(StageFixture.builder().festival(festival).build()); + + // when + List actual = stageRepository.findAllDetailByFestivalId(festival.getId()); + + // then + assertThat(actual).hasSize(1); + } + + @Test + void 티켓_정보까지_모두_조회된다() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + Stage stage = stageRepository.save(StageFixture.builder().festival(festival).build()); + Ticket ticket1 = ticketRepository.save( + TicketFixture.builder().ticketType(TicketType.STUDENT).stage(stage).build()); + Ticket ticket2 = ticketRepository.save( + TicketFixture.builder().ticketType(TicketType.VISITOR).stage(stage).build()); + ticket1.getTicketAmount().addTotalAmount(100); + ticket2.getTicketAmount().addTotalAmount(200); + entityManager.flush(); + entityManager.clear(); + + // when + List actual = stageRepository.findAllDetailByFestivalId(festival.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual.size()).isEqualTo(1); + Stage actualStage = actual.get(0); + softly.assertThat(actualStage.getId()).isEqualTo(stage.getId()); + List actualTickets = actualStage.getTickets(); + softly.assertThat(actualTickets.stream() + .map(Ticket::getTicketType) + .toList()) + .containsExactlyInAnyOrder(TicketType.STUDENT, TicketType.VISITOR); + softly.assertThat(actualTickets.stream() + .map(ticket -> ticket.getTicketAmount().getTotalAmount()) + .toList()) + .containsExactlyInAnyOrder(100, 200); + }); + } +} diff --git a/backend/src/test/java/com/festago/student/application/StudentServiceTest.java b/backend/src/test/java/com/festago/student/application/StudentServiceTest.java index f7ef57a17..5b90f36e8 100644 --- a/backend/src/test/java/com/festago/student/application/StudentServiceTest.java +++ b/backend/src/test/java/com/festago/student/application/StudentServiceTest.java @@ -5,9 +5,9 @@ import static com.festago.common.exception.ErrorCode.INVALID_STUDENT_VERIFICATION_CODE; import static com.festago.common.exception.ErrorCode.MEMBER_NOT_FOUND; import static com.festago.common.exception.ErrorCode.SCHOOL_NOT_FOUND; -import static com.festago.common.exception.ErrorCode.TOO_FREQUENT_REQUESTS; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -15,23 +15,25 @@ import com.festago.common.exception.BadRequestException; import com.festago.common.exception.NotFoundException; -import com.festago.common.exception.TooManyRequestException; import com.festago.member.domain.Member; import com.festago.member.repository.MemberRepository; import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; import com.festago.school.repository.SchoolRepository; +import com.festago.student.domain.Student; import com.festago.student.domain.StudentCode; import com.festago.student.domain.VerificationCode; +import com.festago.student.dto.StudentResponse; import com.festago.student.dto.StudentSendMailRequest; import com.festago.student.dto.StudentVerificateRequest; import com.festago.student.infrastructure.MockMailClient; import com.festago.student.infrastructure.RandomVerificationCodeProvider; import com.festago.student.repository.StudentCodeRepository; import com.festago.student.repository.StudentRepository; -import com.festago.support.MemberFixture; -import com.festago.support.SchoolFixture; import com.festago.support.SetUpMockito; -import com.festago.support.StudentCodeFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StudentFixture; import java.time.Clock; import java.time.LocalDateTime; import java.util.Optional; @@ -83,8 +85,8 @@ class 학생_인증_메일_전송_요청 { @BeforeEach void setUp() { request = new StudentSendMailRequest("ash", 1L); - Member member = MemberFixture.member().id(1L).build(); - School school = SchoolFixture.school().id(1L).build(); + Member member = MemberFixture.builder().id(1L).build(); + School school = SchoolFixture.builder().id(1L).build(); SetUpMockito .given(memberRepository.findById(anyLong())) @@ -127,22 +129,6 @@ void setUp() { .hasMessage(SCHOOL_NOT_FOUND.getMessage()); } - @Test - void 너무_잦은_요청이면_예외() { - // given - StudentSendMailRequest request = new StudentSendMailRequest("ash", 1L); - LocalDateTime currentTime = LocalDateTime.now(clock); - LocalDateTime issuedAt = currentTime.minusSeconds(30); - StudentCode studentCode = StudentCodeFixture.studentCode().issuedAt(issuedAt).build(); - given(studentCodeRepository.findByMemberId(anyLong())) - .willReturn(Optional.of(studentCode)); - - // when - assertThatThrownBy(() -> studentService.sendVerificationMail(1L, request)) - .isInstanceOf(TooManyRequestException.class) - .hasMessage(TOO_FREQUENT_REQUESTS.getMessage()); - } - @Test void 이미_존재하는_학생이면_예외() { // given @@ -186,7 +172,7 @@ class 학생_인증 { .willReturn(true); // when & then - assertThatThrownBy(() -> studentService.verificate(memberId, request)) + assertThatThrownBy(() -> studentService.verify(memberId, request)) .isInstanceOf(BadRequestException.class) .hasMessage(ALREADY_STUDENT_VERIFIED.getMessage()); } @@ -197,12 +183,12 @@ class 학생_인증 { Long memberId = 1L; StudentVerificateRequest request = new StudentVerificateRequest("123456"); given(memberRepository.findById(anyLong())) - .willReturn(Optional.of(MemberFixture.member().build())); + .willReturn(Optional.of(MemberFixture.builder().build())); given(studentCodeRepository.findByCodeAndMember(any(), any())) .willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> studentService.verificate(memberId, request)) + assertThatThrownBy(() -> studentService.verify(memberId, request)) .isInstanceOf(BadRequestException.class) .hasMessage(INVALID_STUDENT_VERIFICATION_CODE.getMessage()); } @@ -212,20 +198,61 @@ class 학생_인증 { // given Long memberId = 1L; StudentVerificateRequest request = new StudentVerificateRequest("123456"); - Member member = MemberFixture.member().build(); + Member member = MemberFixture.builder().build(); given(memberRepository.findById(anyLong())) .willReturn(Optional.of(member)); given(studentCodeRepository.findByCodeAndMember(any(), any())) .willReturn(Optional.of(new StudentCode( new VerificationCode("123456"), - new School("snu.ac.kr", "서울대학교"), + SchoolFixture.builder().domain("snu.ac.kr").name("서울대학교").region(SchoolRegion.서울).build(), member, - "ohs" + "ohs", + LocalDateTime.now() ))); // when & then assertThatNoException() - .isThrownBy(() -> studentService.verificate(memberId, request)); + .isThrownBy(() -> studentService.verify(memberId, request)); + } + } + + @Nested + class 멤버_아이디로_인증정보_조회 { + + @Test + void 학생_인증된_멤버의_경우() { + // given + Long memberId = 1L; + School school = SchoolFixture.builder().id(2L).build(); + Student student = StudentFixture.builder().id(3L).school(school).build(); + given(studentRepository.findByMemberIdWithFetch(memberId)) + .willReturn(Optional.of(student)); + + // when + StudentResponse actual = studentService.findVerification(memberId); + + // then + assertSoftly(softly -> { + softly.assertThat(actual.isVerified()).isTrue(); + softly.assertThat(actual.school().id()).isEqualTo(school.getId()); + }); + } + + @Test + void 학생_인증되지_않은_사용자의_경우() { + // given + Long memberId = 1L; + given(studentRepository.findByMemberIdWithFetch(memberId)) + .willReturn(Optional.empty()); + + // when + StudentResponse actual = studentService.findVerification(memberId); + + // then + assertSoftly(softly -> { + softly.assertThat(actual.isVerified()).isFalse(); + softly.assertThat(actual.school()).isNull(); + }); } } } diff --git a/backend/src/test/java/com/festago/student/domain/StudentCodeTest.java b/backend/src/test/java/com/festago/student/domain/StudentCodeTest.java index 146ca1456..4415c34de 100644 --- a/backend/src/test/java/com/festago/student/domain/StudentCodeTest.java +++ b/backend/src/test/java/com/festago/student/domain/StudentCodeTest.java @@ -1,88 +1,148 @@ package com.festago.student.domain; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.TooManyRequestException; +import com.festago.common.exception.UnexpectedException; +import com.festago.common.exception.ValidException; import com.festago.member.domain.Member; import com.festago.school.domain.School; -import com.festago.support.MemberFixture; -import com.festago.support.SchoolFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") class StudentCodeTest { - @Nested - class 재발급_가능한지_확인 { - - @Test - void 발급한지_30초_이내면_거짓() { - // given - LocalDateTime currentTime = LocalDateTime.now(); - LocalDateTime issuedAt = currentTime.minusSeconds(30); - School school = SchoolFixture.school().build(); - Member member = MemberFixture.member().build(); - StudentCode studentCode = new StudentCode(1L, new VerificationCode("123456"), - school, member, "ash", issuedAt); - - // when - boolean actual = studentCode.canReissue(currentTime); + @Test + void 성공() { + // given + Member member = MemberFixture.builder().build(); + School school = SchoolFixture.builder().build(); + LocalDateTime issuedAt = LocalDateTime.parse("2023-11-19T02:00:00"); + VerificationCode verificationCode = new VerificationCode("123456"); - // then - assertThat(actual).isEqualTo(false); - } + // when + StudentCode studentCode = new StudentCode(1L, verificationCode, school, member, "glen", issuedAt); - @Test - void 발급한지_30초_이후면_참() { - // given - LocalDateTime currentTime = LocalDateTime.now(); - LocalDateTime issuedAt = currentTime.minusSeconds(31); - School school = SchoolFixture.school().build(); - Member member = MemberFixture.member().build(); - StudentCode studentCode = new StudentCode(1L, new VerificationCode("123456"), - school, member, "ash", issuedAt); + // then + assertThat(studentCode.getId()).isEqualTo(1L); + } - // when - boolean actual = studentCode.canReissue(currentTime); + @Test + void username의_길이가_255자를_초과하면_예외() { + // given + Member member = MemberFixture.builder().build(); + School school = SchoolFixture.builder().build(); + LocalDateTime issuedAt = LocalDateTime.parse("2023-11-19T02:00:00"); + VerificationCode verificationCode = new VerificationCode("123456"); + String username = "1".repeat(256); + + // when & then + assertThatThrownBy(() -> new StudentCode(1L, verificationCode, school, member, username, issuedAt)) + .isInstanceOf(ValidException.class) + .hasMessageContaining("username"); + } - // then - assertThat(actual).isEqualTo(true); - } + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t", "\n"}) + void username이_null_또는_공백이면_예외(String username) { + // given + Member member = MemberFixture.builder().build(); + School school = SchoolFixture.builder().build(); + LocalDateTime issuedAt = LocalDateTime.parse("2023-11-19T02:00:00"); + VerificationCode verificationCode = new VerificationCode("123456"); + + // when & then + assertThatThrownBy(() -> new StudentCode(1L, verificationCode, school, member, username, issuedAt)) + .isInstanceOf(ValidException.class) + .hasMessageContaining("username"); } @Test - void 학생인증코드_재발급() { + void 이메일_반환_성공() { // given - LocalDateTime currentTime = LocalDateTime.now(); - LocalDateTime issuedAt = currentTime.minusSeconds(31); + Member member = MemberFixture.builder().build(); + School school = SchoolFixture.builder().domain("fiddich.com").build(); + LocalDateTime issuedAt = LocalDateTime.parse("2023-11-19T02:00:00"); + VerificationCode verificationCode = new VerificationCode("123456"); - VerificationCode oldCode = new VerificationCode("111111"); - VerificationCode newCode = new VerificationCode("222222"); + // when + StudentCode studentCode = new StudentCode(1L, verificationCode, school, member, "glen", issuedAt); - School oldSchool = SchoolFixture.school().build(); - School newSchool = SchoolFixture.school().build(); + // then + assertThat(studentCode.getEmail()).isEqualTo("glen@fiddich.com"); + } - String oldUsername = "ash"; - String newUsername = "pooh"; + @Nested + class 재발급 { - Member member = MemberFixture.member().build(); + @Test + void 재발급할_코드에_식별자가_있으면_예외() { + // given + Member member = MemberFixture.builder().build(); + School school = SchoolFixture.builder().build(); + LocalDateTime issuedAt = LocalDateTime.parse("2023-11-19T02:00:00"); + VerificationCode verificationCode = new VerificationCode("123456"); + StudentCode studentCode = new StudentCode(1L, verificationCode, school, member, "ash", issuedAt); + + LocalDateTime future = LocalDateTime.MAX; + StudentCode newStudentCode = new StudentCode(2L, verificationCode, school, member, "glen", future); + + // when & then + assertThatThrownBy(() -> studentCode.reissue(newStudentCode)) + .isInstanceOf(UnexpectedException.class) + .hasMessage("새로 발급할 인증 코드는 식별자가 없어야 합니다."); + } - StudentCode studentCode = new StudentCode(1L, oldCode, oldSchool, member, oldUsername, issuedAt); + @ParameterizedTest + @ValueSource(longs = {0, 1, 30}) + void 발급한지_30초_이내면_예외(long second) { + // given + Member member = MemberFixture.builder().build(); + School school = SchoolFixture.builder().build(); + LocalDateTime issuedAt = LocalDateTime.parse("2023-11-19T02:00:00"); + VerificationCode verificationCode = new VerificationCode("123456"); + StudentCode studentCode = new StudentCode(1L, verificationCode, school, member, "ash", issuedAt); + + LocalDateTime future = issuedAt.plusSeconds(second); + StudentCode newStudentCode = new StudentCode(null, verificationCode, school, member, "glen", future); + + // when & then + assertThatThrownBy(() -> studentCode.reissue(newStudentCode)) + .isInstanceOf(TooManyRequestException.class) + .hasMessage(ErrorCode.TOO_FREQUENT_REQUESTS.getMessage()); + } - // when - studentCode.reissue(newCode, newSchool, newUsername); + @ParameterizedTest + @ValueSource(longs = {31, 999, 9999}) + void 발급한지_30초_이후면_성공(long second) { + // given + Member member = MemberFixture.builder().build(); + School school = SchoolFixture.builder().build(); + LocalDateTime issuedAt = LocalDateTime.parse("2023-11-19T02:00:00"); + VerificationCode verificationCode = new VerificationCode("123456"); + StudentCode studentCode = new StudentCode(1L, verificationCode, school, member, "ash", issuedAt); - // then - assertSoftly(softly -> { - softly.assertThat(studentCode.getCode()).isEqualTo(newCode); - softly.assertThat(studentCode.getSchool()).isEqualTo(newSchool); - softly.assertThat(studentCode.getUsername()).isEqualTo(newUsername); - softly.assertThat(studentCode.getMember()).isEqualTo(member); - }); + LocalDateTime future = issuedAt.plusSeconds(second); + StudentCode newStudentCode = new StudentCode(null, verificationCode, school, member, "glen", future); + + // when + studentCode.reissue(newStudentCode); + + // then + assertThat(studentCode.getUsername()).isEqualTo("glen"); + } } } diff --git a/backend/src/test/java/com/festago/student/domain/VerificationCodeTest.java b/backend/src/test/java/com/festago/student/domain/VerificationCodeTest.java new file mode 100644 index 000000000..34b1a8a4b --- /dev/null +++ b/backend/src/test/java/com/festago/student/domain/VerificationCodeTest.java @@ -0,0 +1,58 @@ +package com.festago.student.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.ValidException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class VerificationCodeTest { + + @Test + void null_이면_예외() { + // when & then + assertThatThrownBy(() -> new VerificationCode(null)) + .isInstanceOf(ValidException.class) + .hasMessage("VerificationCode은/는 null 또는 공백이 될 수 없습니다."); + } + + @ParameterizedTest + @ValueSource(strings = {"12345", "1234567"}) + void 길이가_6자리가_아니면_예외(String code) { + // when & then + assertThatThrownBy(() -> new VerificationCode(code)) + .isInstanceOf(ValidException.class) + .hasMessage("VerificationCode의 길이는 6 이어야 합니다."); + } + + @Test + void 숫자가_아니면_예외() { + // when & then + assertThatThrownBy(() -> new VerificationCode("일이삼사오육")) + .isInstanceOf(ValidException.class) + .hasMessage("VerificationCode는 0~9의 양수 형식이어야 합니다."); + } + + @Test + void 음수이면_예외() { + // when & then + assertThatThrownBy(() -> new VerificationCode("-12345")) + .isInstanceOf(ValidException.class) + .hasMessage("VerificationCode는 0~9의 양수 형식이어야 합니다."); + } + + @Test + void 생성() { + // when + VerificationCode verificationCode = new VerificationCode("123456"); + + // then + assertThat(verificationCode.getValue()).isEqualTo("123456"); + } +} diff --git a/backend/src/test/java/com/festago/domain/RandomVerificationCodeProviderTest.java b/backend/src/test/java/com/festago/student/infrastructure/RandomVerificationCodeProviderTest.java similarity index 86% rename from backend/src/test/java/com/festago/domain/RandomVerificationCodeProviderTest.java rename to backend/src/test/java/com/festago/student/infrastructure/RandomVerificationCodeProviderTest.java index 1bf3e8a23..f070b0363 100644 --- a/backend/src/test/java/com/festago/domain/RandomVerificationCodeProviderTest.java +++ b/backend/src/test/java/com/festago/student/infrastructure/RandomVerificationCodeProviderTest.java @@ -1,9 +1,8 @@ -package com.festago.domain; +package com.festago.student.infrastructure; import static org.assertj.core.api.Assertions.assertThat; import com.festago.student.domain.VerificationCode; -import com.festago.student.infrastructure.RandomVerificationCodeProvider; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; diff --git a/backend/src/test/java/com/festago/student/repository/StudentRepositoryTest.java b/backend/src/test/java/com/festago/student/repository/StudentRepositoryTest.java new file mode 100644 index 000000000..f6594d855 --- /dev/null +++ b/backend/src/test/java/com/festago/student/repository/StudentRepositoryTest.java @@ -0,0 +1,50 @@ +package com.festago.student.repository; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.member.domain.Member; +import com.festago.member.repository.MemberRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.student.domain.Student; +import com.festago.support.RepositoryTest; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StudentFixture; +import java.util.Optional; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@RepositoryTest +class StudentRepositoryTest { + + @Autowired + StudentRepository studentRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + MemberRepository memberRepository; + + @Test + void 멤버id로_조회() { + // given + Member member = memberRepository.save(MemberFixture.builder().build()); + School school = schoolRepository.save(SchoolFixture.builder().build()); + Student student = studentRepository.save(StudentFixture.builder().school(school).member(member).build()); + + // when + Optional actual = studentRepository.findByMemberIdWithFetch(member.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(actual).isNotEmpty(); + softly.assertThat(actual.get().getId()).isEqualTo(student.getId()); + }); + } +} diff --git a/backend/src/test/java/com/festago/support/AbstractMemoryRepository.java b/backend/src/test/java/com/festago/support/AbstractMemoryRepository.java new file mode 100644 index 000000000..f55b7ae14 --- /dev/null +++ b/backend/src/test/java/com/festago/support/AbstractMemoryRepository.java @@ -0,0 +1,46 @@ +package com.festago.support; + +import jakarta.persistence.Id; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import lombok.SneakyThrows; + +public abstract class AbstractMemoryRepository { + + protected final HashMap memory = new HashMap<>(); + private final AtomicLong autoIncrement = new AtomicLong(); + + @SneakyThrows + final public T save(T entity) { + Field[] fields = entity.getClass() + .getDeclaredFields(); + for (Field field : fields) { + if (field.isAnnotationPresent(Id.class)) { + field.setAccessible(true); + long id = autoIncrement.incrementAndGet(); + field.set(entity, id); + memory.put(id, entity); + return entity; + } + } + throw new IllegalArgumentException("해당 엔티티에 @Id 어노테이션이 붙은 식별자가 존재하지 않습니다."); + } + + final public Optional findById(Long id) { + return Optional.ofNullable(memory.get(id)); + } + + final public boolean existsById(Long id) { + return memory.containsKey(id); + } + + final public void deleteById(Long id) { + memory.remove(id); + } + + final public long count() { + return memory.size(); + } +} diff --git a/backend/src/test/java/com/festago/support/ApplicationIntegrationTest.java b/backend/src/test/java/com/festago/support/ApplicationIntegrationTest.java new file mode 100644 index 000000000..f76220f21 --- /dev/null +++ b/backend/src/test/java/com/festago/support/ApplicationIntegrationTest.java @@ -0,0 +1,15 @@ +package com.festago.support; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestExecutionListeners; + +@SpringBootTest +@TestExecutionListeners(value = { + ResetMockTestExecutionListener.class, + DatabaseClearTestExecutionListener.class +}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS) +@Import({TestTimeConfig.class}) +public abstract class ApplicationIntegrationTest { + +} diff --git a/backend/src/test/java/com/festago/support/CustomWebMvcTest.java b/backend/src/test/java/com/festago/support/CustomWebMvcTest.java index c19dde866..42063b6cb 100644 --- a/backend/src/test/java/com/festago/support/CustomWebMvcTest.java +++ b/backend/src/test/java/com/festago/support/CustomWebMvcTest.java @@ -1,23 +1,21 @@ package com.festago.support; -import com.festago.config.ErrorLoggerConfig; +import com.festago.common.aop.ValidPageableAspect; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.context.annotation.Import; -import org.springframework.core.annotation.AliasFor; import org.springframework.test.context.TestExecutionListeners; import org.springframework.test.context.TestExecutionListeners.MergeMode; +@EnableAspectJAutoProxy @WebMvcTest -@Import({TestAuthConfig.class, ErrorLoggerConfig.class}) +@Import({TestAuthConfig.class, MockAllServiceBeanFactoryPostProcessor.class, ValidPageableAspect.class}) @Retention(RetentionPolicy.RUNTIME) -@TestExecutionListeners(value = MockAuthTestExecutionListener.class, mergeMode = MergeMode.MERGE_WITH_DEFAULTS) +@TestExecutionListeners(value = {MockAuthTestExecutionListener.class, + ResetMockTestExecutionListener.class}, mergeMode = MergeMode.MERGE_WITH_DEFAULTS) public @interface CustomWebMvcTest { - @AliasFor("controllers") Class[] value() default {}; - - @AliasFor("value") - Class[] controllers() default {}; } diff --git a/backend/src/test/java/com/festago/support/DatabaseClearExtension.java b/backend/src/test/java/com/festago/support/DatabaseClearExtension.java deleted file mode 100644 index b2f9b1e3c..000000000 --- a/backend/src/test/java/com/festago/support/DatabaseClearExtension.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.festago.support; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import org.junit.jupiter.api.extension.AfterAllCallback; -import org.junit.jupiter.api.extension.AfterEachCallback; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.api.extension.ExtensionContext.Namespace; -import org.junit.jupiter.api.extension.TestInstanceFactoryContext; -import org.junit.jupiter.api.extension.TestInstancePreConstructCallback; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -public class DatabaseClearExtension implements TestInstancePreConstructCallback, AfterEachCallback, AfterAllCallback { - - private static final Namespace CLEANER = Namespace.create("festago.database.cleaner"); - private static final Set TABLES = new HashSet<>(); - private static final String JDBC_TEMPLATE = "jdbcTemplate"; - - @Override - public void preConstructTestInstance(TestInstanceFactoryContext factoryContext, ExtensionContext context) - throws Exception { - JdbcTemplate jdbcTemplate = SpringExtension.getApplicationContext(context) - .getBean(JdbcTemplate.class); - context.getStore(CLEANER).put(JDBC_TEMPLATE, jdbcTemplate); - initialTable(context); - truncate(context); - } - - private void initialTable(ExtensionContext context) { - if (TABLES.isEmpty()) { - JdbcTemplate jdbcTemplate = context.getStore(CLEANER).get(JDBC_TEMPLATE, JdbcTemplate.class); - List tables = jdbcTemplate.query("SHOW TABLES", (rs, rowNum) -> rs.getString(1)); - TABLES.addAll(tables); - } - } - - private void truncate(ExtensionContext context) { - JdbcTemplate jdbcTemplate = context.getStore(CLEANER).get(JDBC_TEMPLATE, JdbcTemplate.class); - jdbcTemplate.update("SET FOREIGN_KEY_CHECKS = ?", 0); - for (String table : TABLES) { - jdbcTemplate.update("TRUNCATE TABLE " + table); - } - jdbcTemplate.update("SET FOREIGN_KEY_CHECKS = ?", 1); - } - - @Override - public void afterEach(ExtensionContext context) throws Exception { - truncate(context); - } - - @Override - public void afterAll(ExtensionContext context) throws Exception { - context.getStore(CLEANER).remove(JDBC_TEMPLATE); - } -} diff --git a/backend/src/test/java/com/festago/support/DatabaseClearTestExecutionListener.java b/backend/src/test/java/com/festago/support/DatabaseClearTestExecutionListener.java new file mode 100644 index 000000000..ba28ab463 --- /dev/null +++ b/backend/src/test/java/com/festago/support/DatabaseClearTestExecutionListener.java @@ -0,0 +1,49 @@ +package com.festago.support; + +import java.util.HashSet; +import java.util.Set; +import org.springframework.context.ApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.TestExecutionListener; + +public class DatabaseClearTestExecutionListener implements TestExecutionListener { + + private static final Set tables = new HashSet<>(); + private static final ThreadLocal jdbcTemplates = new ThreadLocal<>(); + + @Override + public void prepareTestInstance(TestContext testContext) throws Exception { + ApplicationContext ac = testContext.getApplicationContext(); + JdbcTemplate jdbcTemplate = ac.getBean(JdbcTemplate.class); + jdbcTemplates.set(jdbcTemplate); + initialTable(); + truncate(); + } + + private void initialTable() { + if (tables.isEmpty()) { + JdbcTemplate jdbcTemplate = jdbcTemplates.get(); + tables.addAll(jdbcTemplate.query("SHOW TABLES", (rs, rowNum) -> rs.getString(1))); + } + } + + private void truncate() { + JdbcTemplate jdbcTemplate = jdbcTemplates.get(); + jdbcTemplate.update("SET FOREIGN_KEY_CHECKS = ?", 0); + for (String table : tables) { + jdbcTemplate.update("TRUNCATE TABLE " + table); + } + jdbcTemplate.update("SET FOREIGN_KEY_CHECKS = ?", 1); + } + + @Override + public void afterTestMethod(TestContext testContext) throws Exception { + truncate(); + } + + @Override + public void afterTestClass(TestContext testContext) throws Exception { + jdbcTemplates.remove(); + } +} diff --git a/backend/src/test/java/com/festago/support/FestivalFixture.java b/backend/src/test/java/com/festago/support/FestivalFixture.java deleted file mode 100644 index 5e69ee864..000000000 --- a/backend/src/test/java/com/festago/support/FestivalFixture.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.festago.support; - -import com.festago.festival.domain.Festival; -import com.festago.school.domain.School; -import java.time.LocalDate; - -public class FestivalFixture { - - private Long id; - private String name = "테코 대학교"; - private LocalDate startDate = LocalDate.now(); - private LocalDate endDate = LocalDate.now().plusDays(3L); - private String thumbnail = "https://picsum.photos/536/354"; - private School school = SchoolFixture.school().build(); - - private FestivalFixture() { - } - - public static FestivalFixture festival() { - return new FestivalFixture(); - } - - public FestivalFixture id(Long id) { - this.id = id; - return this; - } - - public FestivalFixture name(String name) { - this.name = name; - return this; - } - - public FestivalFixture startDate(LocalDate startDate) { - this.startDate = startDate; - return this; - } - - public FestivalFixture endDate(LocalDate endDate) { - this.endDate = endDate; - return this; - } - - public FestivalFixture thumbnail(String thumbnail) { - this.thumbnail = thumbnail; - return this; - } - - public FestivalFixture school(School school) { - this.school = school; - return this; - } - - public Festival build() { - return new Festival(id, name, startDate, endDate, thumbnail, school); - } -} diff --git a/backend/src/test/java/com/festago/support/MemberFixture.java b/backend/src/test/java/com/festago/support/MemberFixture.java deleted file mode 100644 index ebc4f8f1d..000000000 --- a/backend/src/test/java/com/festago/support/MemberFixture.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.festago.support; - -import com.festago.auth.domain.SocialType; -import com.festago.member.domain.Member; - -public class MemberFixture { - - private Long id; - private String socialId = "123"; - private SocialType socialType = SocialType.KAKAO; - private String nickname = "nickname"; - private String profileImage = "https://profileImage.com/image.png"; - - public static MemberFixture member() { - return new MemberFixture(); - } - - public MemberFixture id(Long id) { - this.id = id; - return this; - } - - public MemberFixture socialId(String socialId) { - this.socialId = socialId; - return this; - } - - public MemberFixture socialType(SocialType socialType) { - this.socialType = socialType; - return this; - } - - public MemberFixture nickname(String nickname) { - this.nickname = nickname; - return this; - } - - public MemberFixture profileImage(String profileImage) { - this.profileImage = profileImage; - return this; - } - - public Member build() { - return new Member(this.id, this.socialId, this.socialType, this.nickname, this.profileImage); - } -} diff --git a/backend/src/test/java/com/festago/support/MockAllServiceBeanFactoryPostProcessor.java b/backend/src/test/java/com/festago/support/MockAllServiceBeanFactoryPostProcessor.java new file mode 100644 index 000000000..be3ddcb67 --- /dev/null +++ b/backend/src/test/java/com/festago/support/MockAllServiceBeanFactoryPostProcessor.java @@ -0,0 +1,29 @@ +package com.festago.support; + +import static org.mockito.Mockito.mock; + +import org.junit.platform.commons.util.ClassFilter; +import org.junit.platform.commons.util.ReflectionUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.stereotype.Service; + +public class MockAllServiceBeanFactoryPostProcessor implements BeanFactoryPostProcessor { + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + BeanDefinitionRegistry registry = (BeanDefinitionRegistry) beanFactory; + ClassFilter classFilter = ClassFilter.of(clazz -> clazz.isAnnotationPresent(Service.class)); + ReflectionUtils.findAllClassesInPackage("com.festago", classFilter) + .forEach(clazz -> { + BeanDefinition bean = BeanDefinitionBuilder.genericBeanDefinition(clazz) + .getBeanDefinition(); + registry.registerBeanDefinition(clazz.getSimpleName(), bean); + beanFactory.registerSingleton(clazz.getSimpleName(), mock(clazz)); + }); + } +} diff --git a/backend/src/test/java/com/festago/support/MockAuthExtractor.java b/backend/src/test/java/com/festago/support/MockAuthExtractor.java deleted file mode 100644 index cd88b0aed..000000000 --- a/backend/src/test/java/com/festago/support/MockAuthExtractor.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.festago.support; - -import com.festago.auth.application.AuthExtractor; -import com.festago.auth.domain.AuthPayload; -import com.festago.presentation.auth.AuthenticateContext; - -public class MockAuthExtractor implements AuthExtractor { - - private final AuthenticateContext authenticateContext; - - public MockAuthExtractor(AuthenticateContext authenticateContext) { - this.authenticateContext = authenticateContext; - } - - @Override - public AuthPayload extract(String token) { - return new AuthPayload(authenticateContext.getId(), authenticateContext.getRole()); - } -} diff --git a/backend/src/test/java/com/festago/support/MockAuthTestExecutionListener.java b/backend/src/test/java/com/festago/support/MockAuthTestExecutionListener.java index 5cf14879c..9fe5beb09 100644 --- a/backend/src/test/java/com/festago/support/MockAuthTestExecutionListener.java +++ b/backend/src/test/java/com/festago/support/MockAuthTestExecutionListener.java @@ -1,13 +1,16 @@ package com.festago.support; -import com.festago.auth.domain.Role; -import com.festago.presentation.auth.AuthenticateContext; +import com.festago.auth.AuthenticateContext; +import com.festago.auth.domain.authentication.AdminAuthentication; +import com.festago.auth.domain.authentication.AnonymousAuthentication; +import com.festago.auth.domain.authentication.MemberAuthentication; +import com.festago.auth.domain.authentication.Authentication; import java.lang.reflect.Method; import org.springframework.context.ApplicationContext; import org.springframework.test.context.TestContext; -import org.springframework.test.context.support.AbstractTestExecutionListener; +import org.springframework.test.context.TestExecutionListener; -public class MockAuthTestExecutionListener extends AbstractTestExecutionListener { +public class MockAuthTestExecutionListener implements TestExecutionListener { @Override public void beforeTestMethod(TestContext testContext) throws Exception { @@ -16,7 +19,13 @@ public void beforeTestMethod(TestContext testContext) throws Exception { WithMockAuth withMockAuth = testMethod.getDeclaredAnnotation(WithMockAuth.class); ApplicationContext applicationContext = testContext.getApplicationContext(); AuthenticateContext authenticateContext = applicationContext.getBean(AuthenticateContext.class); - authenticateContext.setAuthenticate(withMockAuth.id(), withMockAuth.role()); + long id = withMockAuth.id(); + Authentication authentication = switch (withMockAuth.role()) { + case ANONYMOUS -> AnonymousAuthentication.getInstance(); + case MEMBER -> new MemberAuthentication(id); + case ADMIN -> new AdminAuthentication(id); + }; + authenticateContext.setAuthentication(authentication); } } @@ -24,6 +33,6 @@ public void beforeTestMethod(TestContext testContext) throws Exception { public void afterTestMethod(TestContext testContext) throws Exception { ApplicationContext applicationContext = testContext.getApplicationContext(); AuthenticateContext authenticateContext = applicationContext.getBean(AuthenticateContext.class); - authenticateContext.setAuthenticate(null, Role.ANONYMOUS); + authenticateContext.setAuthentication(AnonymousAuthentication.getInstance()); } } diff --git a/backend/src/test/java/com/festago/support/MockAuthenticationTokenExtractor.java b/backend/src/test/java/com/festago/support/MockAuthenticationTokenExtractor.java new file mode 100644 index 000000000..4005fd9a9 --- /dev/null +++ b/backend/src/test/java/com/festago/support/MockAuthenticationTokenExtractor.java @@ -0,0 +1,19 @@ +package com.festago.support; + +import com.festago.auth.AuthenticateContext; +import com.festago.auth.domain.AuthenticationTokenExtractor; +import com.festago.auth.domain.authentication.Authentication; + +public class MockAuthenticationTokenExtractor implements AuthenticationTokenExtractor { + + private final AuthenticateContext authenticateContext; + + public MockAuthenticationTokenExtractor(AuthenticateContext authenticateContext) { + this.authenticateContext = authenticateContext; + } + + @Override + public Authentication extract(String token) { + return authenticateContext.getAuthentication(); + } +} diff --git a/backend/src/test/java/com/festago/support/RepositoryTest.java b/backend/src/test/java/com/festago/support/RepositoryTest.java new file mode 100644 index 000000000..acc7b28f6 --- /dev/null +++ b/backend/src/test/java/com/festago/support/RepositoryTest.java @@ -0,0 +1,18 @@ +package com.festago.support; + +import com.festago.config.JpaAuditingConfig; +import com.festago.config.QuerydslConfig; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@DataJpaTest(showSql = false) +@Import({QuerydslConfig.class, JpaAuditingConfig.class}) +public @interface RepositoryTest { + +} diff --git a/backend/src/test/java/com/festago/support/ResetMockTestExecutionListener.java b/backend/src/test/java/com/festago/support/ResetMockTestExecutionListener.java new file mode 100644 index 000000000..dc4b70509 --- /dev/null +++ b/backend/src/test/java/com/festago/support/ResetMockTestExecutionListener.java @@ -0,0 +1,38 @@ +package com.festago.support; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.mockito.internal.util.MockUtil; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.TestExecutionListener; + +public class ResetMockTestExecutionListener implements TestExecutionListener { + + private static long applicationContextStartupDate; + private static final List mockCache = new ArrayList<>(); + + @Override + public void afterTestMethod(TestContext testContext) throws Exception { + ApplicationContext applicationContext = testContext.getApplicationContext(); + if (mockCache.isEmpty() || isNewContext(applicationContext)) { + mockCache.clear(); + applicationContextStartupDate = applicationContext.getStartupDate(); + initMocks(applicationContext); + } + mockCache.forEach(MockUtil::resetMock); + } + + private boolean isNewContext(ApplicationContext applicationContext) { + return applicationContext.getStartupDate() != applicationContextStartupDate; + } + + private void initMocks(ApplicationContext applicationContext) { + Arrays.stream(applicationContext.getBeanDefinitionNames()) + .filter(applicationContext::isSingleton) + .map(applicationContext::getBean) + .filter(MockUtil::isMock) + .forEach(mockCache::add); + } +} diff --git a/backend/src/test/java/com/festago/support/SchoolFixture.java b/backend/src/test/java/com/festago/support/SchoolFixture.java deleted file mode 100644 index 357eb4a67..000000000 --- a/backend/src/test/java/com/festago/support/SchoolFixture.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.festago.support; - -import com.festago.school.domain.School; - -public class SchoolFixture { - - private Long id; - - private String domain = "festago.com"; - - private String name = "페스타고 대학교"; - - private SchoolFixture() { - } - - public static SchoolFixture school() { - return new SchoolFixture(); - } - - public SchoolFixture id(Long id) { - this.id = id; - return this; - } - - public SchoolFixture domain(String domain) { - this.domain = domain; - return this; - } - - public SchoolFixture name(String name) { - this.name = name; - return this; - } - - public School build() { - return new School(id, domain, name); - } - -} diff --git a/backend/src/test/java/com/festago/support/StageFixture.java b/backend/src/test/java/com/festago/support/StageFixture.java deleted file mode 100644 index 5c13eda00..000000000 --- a/backend/src/test/java/com/festago/support/StageFixture.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.festago.support; - -import com.festago.festival.domain.Festival; -import com.festago.stage.domain.Stage; -import java.time.LocalDateTime; - -public class StageFixture { - - private Long id; - private LocalDateTime startTime = LocalDateTime.now(); - private String lineUp = "오리, 글렌, 푸우, 애쉬"; - private LocalDateTime ticketOpenTime = startTime.minusWeeks(1); - private Festival festival = FestivalFixture.festival().build(); - - private StageFixture() { - } - - public static StageFixture stage() { - return new StageFixture(); - } - - public StageFixture id(Long id) { - this.id = id; - return this; - } - - public StageFixture startTime(LocalDateTime startTime) { - this.startTime = startTime; - return this; - } - - public StageFixture lineUp(String lineUp) { - this.lineUp = lineUp; - return this; - } - - public StageFixture ticketOpenTime(LocalDateTime ticketOpenTime) { - this.ticketOpenTime = ticketOpenTime; - return this; - } - - - public StageFixture festival(Festival festival) { - this.festival = festival; - return this; - } - - public Stage build() { - return new Stage(id, startTime, lineUp, ticketOpenTime, festival); - } -} diff --git a/backend/src/test/java/com/festago/support/TestAuthConfig.java b/backend/src/test/java/com/festago/support/TestAuthConfig.java index c0a384dee..4c6a103cb 100644 --- a/backend/src/test/java/com/festago/support/TestAuthConfig.java +++ b/backend/src/test/java/com/festago/support/TestAuthConfig.java @@ -1,7 +1,6 @@ package com.festago.support; -import com.festago.auth.application.AuthExtractor; -import com.festago.presentation.auth.AuthenticateContext; +import com.festago.auth.AuthenticateContext; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -14,7 +13,7 @@ public AuthenticateContext authenticateContext() { } @Bean - public AuthExtractor authExtractor(AuthenticateContext authenticateContext) { - return new MockAuthExtractor(authenticateContext); + public MockAuthenticationTokenExtractor mockAuthenticationTokenExtractor(AuthenticateContext authenticateContext) { + return new MockAuthenticationTokenExtractor(authenticateContext); } } diff --git a/backend/src/test/java/com/festago/support/TestTimeConfig.java b/backend/src/test/java/com/festago/support/TestTimeConfig.java new file mode 100644 index 000000000..a273907ae --- /dev/null +++ b/backend/src/test/java/com/festago/support/TestTimeConfig.java @@ -0,0 +1,17 @@ +package com.festago.support; + +import java.time.Clock; +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@TestConfiguration +public class TestTimeConfig { + + @Bean("testClock") + @Primary + public Clock clock() { + return Mockito.spy(Clock.systemDefaultZone()); + } +} diff --git a/backend/src/test/java/com/festago/support/TimeInstantProvider.java b/backend/src/test/java/com/festago/support/TimeInstantProvider.java index 3de862d1e..0016eef5e 100644 --- a/backend/src/test/java/com/festago/support/TimeInstantProvider.java +++ b/backend/src/test/java/com/festago/support/TimeInstantProvider.java @@ -1,6 +1,7 @@ package com.festago.support; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -14,4 +15,8 @@ public static Instant from(String localDateTime) { public static Instant from(LocalDateTime localDateTime) { return Instant.from(ZonedDateTime.of(localDateTime, ZoneId.systemDefault())); } + + public static Instant from(LocalDate localDate) { + return localDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + } } diff --git a/backend/src/test/java/com/festago/support/fixture/AdminFixture.java b/backend/src/test/java/com/festago/support/fixture/AdminFixture.java new file mode 100644 index 000000000..e3023b8f9 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/AdminFixture.java @@ -0,0 +1,33 @@ +package com.festago.support.fixture; + +import com.festago.admin.domain.Admin; + +public class AdminFixture extends BaseFixture { + + private String username; + private String password = "123456"; + + private AdminFixture() { + } + + public static AdminFixture builder() { + return new AdminFixture(); + } + + public AdminFixture username(String username) { + this.username = username; + return this; + } + + public AdminFixture password(String password) { + this.password = password; + return this; + } + + public Admin build() { + return new Admin( + uniqueValue("admin", username), + password + ); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/ArtistFixture.java b/backend/src/test/java/com/festago/support/fixture/ArtistFixture.java new file mode 100644 index 000000000..7b349d470 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/ArtistFixture.java @@ -0,0 +1,44 @@ +package com.festago.support.fixture; + +import com.festago.artist.domain.Artist; + +public class ArtistFixture extends BaseFixture { + + private Long id; + private String name; + private String profileImageUrl = "https://www.festago-image.com/profile.png"; + private String backgroundImageUrl = "https://www.festago-image.com/background.png"; + + public static ArtistFixture builder() { + return new ArtistFixture(); + } + + public ArtistFixture id(Long id) { + this.id = id; + return this; + } + + public ArtistFixture name(String name) { + this.name = name; + return this; + } + + public ArtistFixture profileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + return this; + } + + public ArtistFixture backgroundImageUrl(String backgroundImageUrl) { + this.backgroundImageUrl = backgroundImageUrl; + return this; + } + + public Artist build() { + return new Artist( + id, + uniqueValue("아티스트", name), + profileImageUrl, + backgroundImageUrl + ); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/BaseFixture.java b/backend/src/test/java/com/festago/support/fixture/BaseFixture.java new file mode 100644 index 000000000..870e8cb9d --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/BaseFixture.java @@ -0,0 +1,23 @@ +package com.festago.support.fixture; + +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.util.StringUtils; + +public abstract class BaseFixture { + + protected static final AtomicLong sequence = new AtomicLong(); + + /** + * value가 null 또는 빈 문자열이면 baseValue 뒤에 숫자를 붙여서 유니크한 이름을 만듭니다. + * + * @param defaultValue 기본 값이 될 문자열 + * @param value null 또는 빈 문자열이 될 수 있는 이름 + * @return value가 null 또는 빈 문자열이면 defaultValue 뒤에 숫자가 붙은 문자열 그게 아니면 value 그대로 반환 + */ + protected String uniqueValue(String defaultValue, String value) { + if (!StringUtils.hasText(value)) { + return defaultValue + sequence.incrementAndGet(); + } + return value; + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/BookmarkFixture.java b/backend/src/test/java/com/festago/support/fixture/BookmarkFixture.java new file mode 100644 index 000000000..b2d7c1c85 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/BookmarkFixture.java @@ -0,0 +1,45 @@ +package com.festago.support.fixture; + +import com.festago.bookmark.domain.Bookmark; +import com.festago.bookmark.domain.BookmarkType; + +public class BookmarkFixture extends BaseFixture { + + private Long id; + private BookmarkType bookmarkType; + private Long resourceId; + private Long memberId; + + public static BookmarkFixture builder() { + return new BookmarkFixture(); + } + + public BookmarkFixture id(Long id) { + this.id = id; + return this; + } + + public BookmarkFixture bookmarkType(BookmarkType bookmarkType) { + this.bookmarkType = bookmarkType; + return this; + } + + public BookmarkFixture resourceId(Long resourceId) { + this.resourceId = resourceId; + return this; + } + + public BookmarkFixture memberId(Long memberId) { + this.memberId = memberId; + return this; + } + + public Bookmark build() { + return new Bookmark( + id, + bookmarkType, + resourceId, + memberId + ); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/FestivalFixture.java b/backend/src/test/java/com/festago/support/fixture/FestivalFixture.java new file mode 100644 index 000000000..a677b27ba --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/FestivalFixture.java @@ -0,0 +1,61 @@ +package com.festago.support.fixture; + +import com.festago.festival.domain.Festival; +import com.festago.festival.domain.FestivalDuration; +import com.festago.school.domain.School; +import java.time.LocalDate; + +public class FestivalFixture extends BaseFixture { + + private Long id; + private String name; + private LocalDate startDate = LocalDate.now(); + private LocalDate endDate; + private String posterImageUrl = "https://picsum.photos/536/354"; + private School school = SchoolFixture.builder().build(); + + private FestivalFixture() { + } + + public static FestivalFixture builder() { + return new FestivalFixture(); + } + + public FestivalFixture id(Long id) { + this.id = id; + return this; + } + + public FestivalFixture name(String name) { + this.name = name; + return this; + } + + public FestivalFixture startDate(LocalDate startDate) { + this.startDate = startDate; + return this; + } + + public FestivalFixture endDate(LocalDate endDate) { + this.endDate = endDate; + return this; + } + + public FestivalFixture posterImageUrl(String posterImageUrl) { + this.posterImageUrl = posterImageUrl; + return this; + } + + public FestivalFixture school(School school) { + this.school = school; + return this; + } + + public Festival build() { + if (endDate == null) { + endDate = startDate.plusDays(3L); + } + return new Festival(id, uniqueValue("페스타고 대학교 축제", name), new FestivalDuration(startDate, endDate), + posterImageUrl, school); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/FestivalQueryInfoFixture.java b/backend/src/test/java/com/festago/support/fixture/FestivalQueryInfoFixture.java new file mode 100644 index 000000000..526e908e4 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/FestivalQueryInfoFixture.java @@ -0,0 +1,33 @@ +package com.festago.support.fixture; + +import com.festago.festival.domain.FestivalQueryInfo; +import java.util.Collections; + +public class FestivalQueryInfoFixture extends BaseFixture { + + private Long festivalId; + private String artistInfo = "[]"; + + private FestivalQueryInfoFixture() { + } + + public static FestivalQueryInfoFixture builder() { + return new FestivalQueryInfoFixture(); + } + + public FestivalQueryInfoFixture festivalId(Long festivalId) { + this.festivalId = festivalId; + return this; + } + + public FestivalQueryInfoFixture artistInfo(String artistInfo) { + this.artistInfo = artistInfo; + return this; + } + + public FestivalQueryInfo build() { + FestivalQueryInfo festivalQueryInfo = FestivalQueryInfo.create(festivalId); + festivalQueryInfo.updateArtistInfo(Collections.emptyList(), artists -> artistInfo); + return festivalQueryInfo; + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/MemberFixture.java b/backend/src/test/java/com/festago/support/fixture/MemberFixture.java new file mode 100644 index 000000000..4e1ae91b4 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/MemberFixture.java @@ -0,0 +1,52 @@ +package com.festago.support.fixture; + +import com.festago.auth.domain.SocialType; +import com.festago.member.domain.Member; + +public class MemberFixture extends BaseFixture { + + private Long id; + private String socialId; + private SocialType socialType = SocialType.FESTAGO; + private String nickname; + private String profileImageUrl = "https://image.com/profileImage.png"; + + public static MemberFixture builder() { + return new MemberFixture(); + } + + public MemberFixture id(Long id) { + this.id = id; + return this; + } + + public MemberFixture socialId(String socialId) { + this.socialId = socialId; + return this; + } + + public MemberFixture socialType(SocialType socialType) { + this.socialType = socialType; + return this; + } + + public MemberFixture nickname(String nickname) { + this.nickname = nickname; + return this; + } + + public MemberFixture profileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + return this; + } + + public Member build() { + return new Member( + id, + uniqueValue("", socialId), + socialType, + uniqueValue("nickname", nickname), + profileImageUrl + ); + } +} diff --git a/backend/src/test/java/com/festago/support/MemberTicketFixture.java b/backend/src/test/java/com/festago/support/fixture/MemberTicketFixture.java similarity index 85% rename from backend/src/test/java/com/festago/support/MemberTicketFixture.java rename to backend/src/test/java/com/festago/support/fixture/MemberTicketFixture.java index 784c59abd..8deb9484f 100644 --- a/backend/src/test/java/com/festago/support/MemberTicketFixture.java +++ b/backend/src/test/java/com/festago/support/fixture/MemberTicketFixture.java @@ -1,4 +1,4 @@ -package com.festago.support; +package com.festago.support.fixture; import com.festago.member.domain.Member; import com.festago.stage.domain.Stage; @@ -6,11 +6,12 @@ import com.festago.ticketing.domain.MemberTicket; import java.time.LocalDateTime; +@Deprecated public class MemberTicketFixture { private Long id; - private Member owner = MemberFixture.member().build(); - private Stage stage = StageFixture.stage().build(); + private Member owner = MemberFixture.builder().build(); + private Stage stage = StageFixture.builder().build(); private TicketType ticketType = TicketType.VISITOR; private LocalDateTime entryTime = LocalDateTime.now(); private int number = 1; @@ -18,7 +19,7 @@ public class MemberTicketFixture { private MemberTicketFixture() { } - public static MemberTicketFixture memberTicket() { + public static MemberTicketFixture builder() { return new MemberTicketFixture(); } diff --git a/backend/src/test/java/com/festago/support/fixture/RefreshTokenFixture.java b/backend/src/test/java/com/festago/support/fixture/RefreshTokenFixture.java new file mode 100644 index 000000000..8ed70b1ad --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/RefreshTokenFixture.java @@ -0,0 +1,31 @@ +package com.festago.support.fixture; + +import com.festago.auth.domain.RefreshToken; +import java.time.LocalDateTime; + +public class RefreshTokenFixture extends BaseFixture { + + private Long memberId; + private LocalDateTime expiredAt = LocalDateTime.now().plusWeeks(1); + + private RefreshTokenFixture() { + } + + public static RefreshTokenFixture builder() { + return new RefreshTokenFixture(); + } + + public RefreshTokenFixture memberId(Long memberId) { + this.memberId = memberId; + return this; + } + + public RefreshTokenFixture expiredAt(LocalDateTime expiredAt) { + this.expiredAt = expiredAt; + return this; + } + + public RefreshToken build() { + return new RefreshToken(memberId, expiredAt); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/SchoolFixture.java b/backend/src/test/java/com/festago/support/fixture/SchoolFixture.java new file mode 100644 index 000000000..bb3dd75fb --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/SchoolFixture.java @@ -0,0 +1,62 @@ +package com.festago.support.fixture; + +import com.festago.school.domain.School; +import com.festago.school.domain.SchoolRegion; + +public class SchoolFixture extends BaseFixture { + + private Long id; + private String domain; + private String name; + private SchoolRegion region = SchoolRegion.서울; + private String logoUrl = "https://image.com/logo.png"; + private String backgroundImageUrl = "https://image.com/backgroundImage.png"; + + private SchoolFixture() { + } + + public static SchoolFixture builder() { + return new SchoolFixture(); + } + + public SchoolFixture id(Long id) { + this.id = id; + return this; + } + + public SchoolFixture domain(String domain) { + this.domain = domain; + return this; + } + + public SchoolFixture name(String name) { + this.name = name; + return this; + } + + public SchoolFixture region(SchoolRegion region) { + this.region = region; + return this; + } + + public SchoolFixture logoUrl(String logoUrl) { + this.logoUrl = logoUrl; + return this; + } + + public SchoolFixture backgroundImageUrl(String backgroundImageUrl) { + this.backgroundImageUrl = backgroundImageUrl; + return this; + } + + public School build() { + return new School( + id, + uniqueValue("festago.com", domain), + uniqueValue("페스타고 대학교", name), + logoUrl, + backgroundImageUrl, + region + ); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/SocialMediaFixture.java b/backend/src/test/java/com/festago/support/fixture/SocialMediaFixture.java new file mode 100644 index 000000000..191900dbb --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/SocialMediaFixture.java @@ -0,0 +1,67 @@ +package com.festago.support.fixture; + +import com.festago.socialmedia.domain.OwnerType; +import com.festago.socialmedia.domain.SocialMedia; +import com.festago.socialmedia.domain.SocialMediaType; + +public class SocialMediaFixture { + + private Long id; + private Long ownerId; + private OwnerType ownerType; + private SocialMediaType mediaType = SocialMediaType.INSTAGRAM; + private String name = "총학생회 인스타그램"; + private String logoUrl = "https://image.com/logo.png"; + private String url = "https://instagram.com"; + + public static SocialMediaFixture builder() { + return new SocialMediaFixture(); + } + + public SocialMediaFixture id(Long id) { + this.id = id; + return this; + } + + public SocialMediaFixture ownerId(Long ownerId) { + this.ownerId = ownerId; + return this; + } + + public SocialMediaFixture ownerType(OwnerType ownerType) { + this.ownerType = ownerType; + return this; + } + + public SocialMediaFixture mediaType(SocialMediaType mediaType) { + this.mediaType = mediaType; + return this; + } + + public SocialMediaFixture name(String name) { + this.name = name; + return this; + } + + public SocialMediaFixture logoUrl(String logoUrl) { + this.logoUrl = logoUrl; + return this; + } + + public SocialMediaFixture url(String url) { + this.url = url; + return this; + } + + public SocialMedia build() { + return new SocialMedia( + id, + ownerId, + ownerType, + mediaType, + name, + logoUrl, + url + ); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/StageArtistFixture.java b/backend/src/test/java/com/festago/support/fixture/StageArtistFixture.java new file mode 100644 index 000000000..54f056bff --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/StageArtistFixture.java @@ -0,0 +1,30 @@ +package com.festago.support.fixture; + +import com.festago.stage.domain.StageArtist; + +public class StageArtistFixture extends BaseFixture { + + private Long id; + private Long stageId; + private Long artistId; + + public static StageArtistFixture builder(Long stageId, Long artistId) { + StageArtistFixture stageArtistFixture = new StageArtistFixture(); + stageArtistFixture.stageId = stageId; + stageArtistFixture.artistId = artistId; + return stageArtistFixture; + } + + public StageArtistFixture id(Long id) { + this.id = id; + return this; + } + + public StageArtist build() { + return new StageArtist( + id, + stageId, + artistId + ); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/StageFixture.java b/backend/src/test/java/com/festago/support/fixture/StageFixture.java new file mode 100644 index 000000000..a0cbc908c --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/StageFixture.java @@ -0,0 +1,49 @@ +package com.festago.support.fixture; + +import com.festago.festival.domain.Festival; +import com.festago.stage.domain.Stage; +import java.time.LocalDateTime; + +public class StageFixture extends BaseFixture { + + private Long id; + private LocalDateTime startTime = LocalDateTime.now(); + private LocalDateTime ticketOpenTime; + private Festival festival = FestivalFixture.builder().build(); + + private StageFixture() { + } + + public static StageFixture builder() { + return new StageFixture(); + } + + public StageFixture id(Long id) { + this.id = id; + return this; + } + + public StageFixture startTime(LocalDateTime startTime) { + this.startTime = startTime; + return this; + } + + public StageFixture ticketOpenTime(LocalDateTime ticketOpenTime) { + this.ticketOpenTime = ticketOpenTime; + return this; + } + + public StageFixture festival(Festival festival) { + this.festival = festival; + return this; + } + + public Stage build() { + return new Stage( + id, + startTime, + ticketOpenTime == null ? startTime.minusWeeks(1) : ticketOpenTime, + festival + ); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/StageQueryInfoFixture.java b/backend/src/test/java/com/festago/support/fixture/StageQueryInfoFixture.java new file mode 100644 index 000000000..499ee67b0 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/StageQueryInfoFixture.java @@ -0,0 +1,30 @@ +package com.festago.support.fixture; + +import com.festago.stage.domain.StageQueryInfo; + +public class StageQueryInfoFixture extends BaseFixture { + + private Long stageId; + private String artistInfo; + + private StageQueryInfoFixture() { + } + + public static StageQueryInfoFixture builder() { + return new StageQueryInfoFixture(); + } + + public StageQueryInfoFixture stageId(Long stageId) { + this.stageId = stageId; + return this; + } + + public StageQueryInfoFixture artistInfo(String artistInfo) { + this.artistInfo = artistInfo; + return this; + } + + public StageQueryInfo build() { + return StageQueryInfo.of(stageId, null, ignore -> artistInfo); + } +} diff --git a/backend/src/test/java/com/festago/support/StudentCodeFixture.java b/backend/src/test/java/com/festago/support/fixture/StudentCodeFixture.java similarity index 85% rename from backend/src/test/java/com/festago/support/StudentCodeFixture.java rename to backend/src/test/java/com/festago/support/fixture/StudentCodeFixture.java index 3fe3c1bfa..3401b8728 100644 --- a/backend/src/test/java/com/festago/support/StudentCodeFixture.java +++ b/backend/src/test/java/com/festago/support/fixture/StudentCodeFixture.java @@ -1,4 +1,4 @@ -package com.festago.support; +package com.festago.support.fixture; import com.festago.member.domain.Member; import com.festago.school.domain.School; @@ -6,19 +6,20 @@ import com.festago.student.domain.VerificationCode; import java.time.LocalDateTime; +@Deprecated public class StudentCodeFixture { private Long id; private VerificationCode code = new VerificationCode("123456"); - private School school = SchoolFixture.school().build(); - private Member member = MemberFixture.member().build(); + private School school = SchoolFixture.builder().build(); + private Member member = MemberFixture.builder().build(); private String username = "ash"; private LocalDateTime issuedAt = LocalDateTime.now(); private StudentCodeFixture() { } - public static StudentCodeFixture studentCode() { + public static StudentCodeFixture builder() { return new StudentCodeFixture(); } diff --git a/backend/src/test/java/com/festago/support/fixture/StudentFixture.java b/backend/src/test/java/com/festago/support/fixture/StudentFixture.java new file mode 100644 index 000000000..ba0ba95a3 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/StudentFixture.java @@ -0,0 +1,45 @@ +package com.festago.support.fixture; + +import com.festago.member.domain.Member; +import com.festago.school.domain.School; +import com.festago.student.domain.Student; + +@Deprecated +public class StudentFixture { + + private Long id; + private Member member = MemberFixture.builder().build(); + private School school = SchoolFixture.builder().build(); + private String username = "xxeol2"; + + private StudentFixture() { + } + + public static StudentFixture builder() { + return new StudentFixture(); + } + + public StudentFixture id(Long id) { + this.id = id; + return this; + } + + public StudentFixture member(Member member) { + this.member = member; + return this; + } + + public StudentFixture school(School school) { + this.school = school; + return this; + } + + public StudentFixture username(String username) { + this.username = username; + return this; + } + + public Student build() { + return new Student(id, member, school, username); + } +} diff --git a/backend/src/test/java/com/festago/support/TicketFixture.java b/backend/src/test/java/com/festago/support/fixture/TicketFixture.java similarity index 85% rename from backend/src/test/java/com/festago/support/TicketFixture.java rename to backend/src/test/java/com/festago/support/fixture/TicketFixture.java index 266ab00be..23b95c1b0 100644 --- a/backend/src/test/java/com/festago/support/TicketFixture.java +++ b/backend/src/test/java/com/festago/support/fixture/TicketFixture.java @@ -1,20 +1,21 @@ -package com.festago.support; +package com.festago.support.fixture; import com.festago.stage.domain.Stage; import com.festago.ticket.domain.Ticket; import com.festago.ticket.domain.TicketType; +@Deprecated public class TicketFixture { private Long id; - private Stage stage = StageFixture.stage().build(); + private Stage stage = StageFixture.builder().build(); private TicketType ticketType = TicketType.VISITOR; private Long schoolId = 1L; private TicketFixture() { } - public static TicketFixture ticket() { + public static TicketFixture builder() { return new TicketFixture(); } diff --git a/backend/src/test/java/com/festago/support/fixture/UploadFileFixture.java b/backend/src/test/java/com/festago/support/fixture/UploadFileFixture.java new file mode 100644 index 000000000..6498bbc26 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/UploadFileFixture.java @@ -0,0 +1,82 @@ +package com.festago.support.fixture; + +import com.festago.upload.domain.FileExtension; +import com.festago.upload.domain.FileOwnerType; +import com.festago.upload.domain.UploadFile; +import java.net.URI; +import java.time.LocalDateTime; + +public class UploadFileFixture { + + private long size = 0; + private URI location = URI.create("https://festago.com"); + private FileExtension extension = FileExtension.PNG; + private LocalDateTime createdAt = LocalDateTime.now(); + private Long ownerId = 1L; + private FileOwnerType ownerType = FileOwnerType.FESTIVAL; + + private UploadFileFixture() { + } + + public UploadFileFixture size(long size) { + this.size = size; + return this; + } + + public UploadFileFixture location(URI location) { + this.location = location; + return this; + } + + public UploadFileFixture extension(FileExtension extension) { + this.extension = extension; + return this; + } + + public UploadFileFixture createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public UploadFileFixture ownerId(Long ownerId) { + this.ownerId = ownerId; + return this; + } + + public UploadFileFixture ownerType(FileOwnerType ownerType) { + this.ownerType = ownerType; + return this; + } + + public static UploadFileFixture builder() { + return new UploadFileFixture(); + } + + public UploadFile build() { + return new UploadFile( + size, + location, + extension, + createdAt + ); + } + + public UploadFile buildAssigned() { + UploadFile uploadFile = build(); + uploadFile.changeAssigned(ownerId, ownerType); + return uploadFile; + } + + public UploadFile buildAttached() { + UploadFile uploadFile = build(); + uploadFile.changeAttached(ownerId, ownerType); + return uploadFile; + } + + public UploadFile buildAbandoned() { + UploadFile uploadFile = build(); + uploadFile.changeAttached(ownerId, ownerType); + uploadFile.changeAbandoned(); + return uploadFile; + } +} diff --git a/backend/src/test/java/com/festago/application/TicketServiceTest.java b/backend/src/test/java/com/festago/ticket/application/TicketServiceTest.java similarity index 75% rename from backend/src/test/java/com/festago/application/TicketServiceTest.java rename to backend/src/test/java/com/festago/ticket/application/TicketServiceTest.java index cca7c198a..c134ff544 100644 --- a/backend/src/test/java/com/festago/application/TicketServiceTest.java +++ b/backend/src/test/java/com/festago/ticket/application/TicketServiceTest.java @@ -1,12 +1,11 @@ -package com.festago.application; +package com.festago.ticket.application; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import com.festago.stage.domain.Stage; -import com.festago.support.StageFixture; -import com.festago.support.TicketFixture; -import com.festago.ticket.application.TicketService; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.TicketFixture; import com.festago.ticket.domain.Ticket; import com.festago.ticket.domain.TicketType; import com.festago.ticket.dto.StageTicketResponse; @@ -21,9 +20,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) class TicketServiceTest { @Mock @@ -36,12 +35,12 @@ class TicketServiceTest { void 공연_아이디로_모든_티켓의_정보_조회() { // given long stageId = 1L; - Stage stage = StageFixture.stage().id(stageId).build(); + Stage stage = StageFixture.builder().id(stageId).build(); List tickets = List.of( - TicketFixture.ticket().id(1L).ticketType(TicketType.STUDENT).stage(stage).build(), - TicketFixture.ticket().id(2L).ticketType(TicketType.VISITOR).stage(stage).build() + TicketFixture.builder().id(1L).ticketType(TicketType.STUDENT).stage(stage).build(), + TicketFixture.builder().id(2L).ticketType(TicketType.VISITOR).stage(stage).build() ); - given(ticketRepository.findAllByStageId(stageId)) + given(ticketRepository.findAllByStageIdWithFetch(stageId)) .willReturn(tickets); // when diff --git a/backend/src/test/java/com/festago/application/integration/TicketServiceIntegrationTest.java b/backend/src/test/java/com/festago/ticket/application/integration/TicketServiceIntegrationTest.java similarity index 87% rename from backend/src/test/java/com/festago/application/integration/TicketServiceIntegrationTest.java rename to backend/src/test/java/com/festago/ticket/application/integration/TicketServiceIntegrationTest.java index 8f50652b7..16c9f1a24 100644 --- a/backend/src/test/java/com/festago/application/integration/TicketServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/ticket/application/integration/TicketServiceIntegrationTest.java @@ -1,4 +1,4 @@ -package com.festago.application.integration; +package com.festago.ticket.application.integration; import static com.festago.common.exception.ErrorCode.STAGE_NOT_FOUND; import static org.assertj.core.api.Assertions.assertThat; @@ -12,9 +12,10 @@ import com.festago.school.repository.SchoolRepository; import com.festago.stage.domain.Stage; import com.festago.stage.repository.StageRepository; -import com.festago.support.FestivalFixture; -import com.festago.support.SchoolFixture; -import com.festago.support.StageFixture; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; import com.festago.ticket.application.TicketService; import com.festago.ticket.domain.TicketAmount; import com.festago.ticket.domain.TicketType; @@ -29,7 +30,6 @@ import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.SpyBean; @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") @@ -53,7 +53,7 @@ class TicketServiceIntegrationTest extends ApplicationIntegrationTest { @Autowired SchoolRepository schoolRepository; - @SpyBean + @Autowired Clock clock; @Test @@ -77,15 +77,14 @@ class TicketServiceIntegrationTest extends ApplicationIntegrationTest { // given LocalDateTime stageStartTime = LocalDateTime.parse("2022-07-26T18:00:00"); doReturn(stageStartTime.minusWeeks(1).toInstant(ZoneOffset.UTC)) - .when(clock) - .instant(); - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival() + .when(clock).instant(); + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder() .school(school) .startDate(stageStartTime.toLocalDate()) .endDate(stageStartTime.toLocalDate()) .build()); - Stage stage = stageRepository.save(StageFixture.stage() + Stage stage = stageRepository.save(StageFixture.builder() .festival(festival) .startTime(stageStartTime) .ticketOpenTime(stageStartTime.minusDays(1)) @@ -106,15 +105,14 @@ class TicketServiceIntegrationTest extends ApplicationIntegrationTest { // given LocalDateTime stageStartTime = LocalDateTime.parse("2022-07-26T18:00:00"); doReturn(stageStartTime.minusWeeks(1).toInstant(ZoneOffset.UTC)) - .when(clock) - .instant(); - School school = schoolRepository.save(SchoolFixture.school().build()); - Festival festival = festivalRepository.save(FestivalFixture.festival() + .when(clock).instant(); + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder() .school(school) .startDate(stageStartTime.toLocalDate()) .endDate(stageStartTime.toLocalDate()) .build()); - Stage stage = stageRepository.save(StageFixture.stage() + Stage stage = stageRepository.save(StageFixture.builder() .festival(festival) .startTime(stageStartTime) .ticketOpenTime(stageStartTime.minusDays(1)) diff --git a/backend/src/test/java/com/festago/ticket/domain/ReservationSequenceTest.java b/backend/src/test/java/com/festago/ticket/domain/ReservationSequenceTest.java new file mode 100644 index 000000000..8ac534d57 --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/domain/ReservationSequenceTest.java @@ -0,0 +1,25 @@ +package com.festago.ticket.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ReservationSequenceTest { + + @ParameterizedTest + @ValueSource(ints = {-1, 0}) + void 예약_순서는_양의_1보다_커야한다(int sequence) { + // given & when & then + assertThatThrownBy(() -> new ReservationSequence(sequence)) + .isInstanceOf(InternalServerException.class) + .hasMessage(ErrorCode.TICKET_SEQUENCE_DATA_ERROR.getMessage()); + } + +} diff --git a/backend/src/test/java/com/festago/domain/TicketAmountTest.java b/backend/src/test/java/com/festago/ticket/domain/TicketAmountTest.java similarity index 92% rename from backend/src/test/java/com/festago/domain/TicketAmountTest.java rename to backend/src/test/java/com/festago/ticket/domain/TicketAmountTest.java index 167eff3c6..2e4828884 100644 --- a/backend/src/test/java/com/festago/domain/TicketAmountTest.java +++ b/backend/src/test/java/com/festago/ticket/domain/TicketAmountTest.java @@ -1,10 +1,9 @@ -package com.festago.domain; +package com.festago.ticket.domain; import static com.festago.common.exception.ErrorCode.TICKET_SOLD_OUT; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.festago.common.exception.BadRequestException; -import com.festago.ticket.domain.TicketAmount; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; diff --git a/backend/src/test/java/com/festago/ticket/domain/TicketEntryTimeTest.java b/backend/src/test/java/com/festago/ticket/domain/TicketEntryTimeTest.java new file mode 100644 index 000000000..d4aa10e4d --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/domain/TicketEntryTimeTest.java @@ -0,0 +1,27 @@ +package com.festago.ticket.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.ValidException; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class TicketEntryTimeTest { + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void 총_수량이_0_또는_음수일_경우_에외(int amount) { + // given + LocalDateTime now = LocalDateTime.now(); + + // when & then + assertThatThrownBy(() -> new TicketEntryTime(now, amount)) + .isInstanceOf(ValidException.class) + .hasMessage("amount은/는 1 이상이어야 합니다."); + } +} diff --git a/backend/src/test/java/com/festago/ticket/domain/TicketTest.java b/backend/src/test/java/com/festago/ticket/domain/TicketTest.java new file mode 100644 index 000000000..633fead66 --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/domain/TicketTest.java @@ -0,0 +1,161 @@ +package com.festago.ticket.domain; + +import static com.festago.common.exception.ErrorCode.EARLY_TICKET_ENTRY_THAN_OPEN; +import static com.festago.common.exception.ErrorCode.EARLY_TICKET_ENTRY_TIME; +import static com.festago.common.exception.ErrorCode.INVALID_TICKET_CREATE_TIME; +import static com.festago.common.exception.ErrorCode.LATE_TICKET_ENTRY_TIME; +import static com.festago.common.exception.ErrorCode.TICKET_SOLD_OUT; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.common.exception.BadRequestException; +import com.festago.festival.domain.Festival; +import com.festago.member.domain.Member; +import com.festago.stage.domain.Stage; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.TicketFixture; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class TicketTest { + + @Nested + class 입장시간_추가_검증 { + + @ParameterizedTest + @ValueSource(longs = {0, 1}) + void 입장시간이_티켓오픈시간_이전이면_예외(long minute) { + // given + LocalDateTime now = LocalDateTime.now(); + Stage stage = StageFixture.builder() + .startTime(now.plusDays(1)) + .ticketOpenTime(now) + .build(); + Ticket ticket = TicketFixture.builder() + .stage(stage) + .build(); + + // when & then + assertThatThrownBy( + () -> ticket.addTicketEntryTime(now.minusMinutes(10), now.minusMinutes(minute), 100)) + .isInstanceOf(BadRequestException.class) + .hasMessage(EARLY_TICKET_ENTRY_THAN_OPEN.getMessage()); + } + + @ParameterizedTest + @ValueSource(longs = {0, 1}) + void 입장_시간이_축제_시작_시간보다_같거나_이후면_예외(long minute) { + // given + Ticket ticket = TicketFixture.builder() + .build(); + + Stage stage = ticket.getStage(); + LocalDateTime stageStartTime = stage.getStartTime(); + LocalDateTime entryTime = stageStartTime.plusMinutes(minute); + LocalDateTime ticketOpenTime = stage.getTicketOpenTime(); + + // when & then + assertThatThrownBy(() -> ticket.addTicketEntryTime(ticketOpenTime.minusMinutes(10), entryTime, 100)) + .isInstanceOf(BadRequestException.class) + .hasMessage(LATE_TICKET_ENTRY_TIME.getMessage()); + } + + @Test + void 입장_시간이_공연_시작_12시간_이전이면_예외() { + // given + Ticket ticket = TicketFixture.builder() + .build(); + + Stage stage = ticket.getStage(); + LocalDateTime stageStartTime = stage.getStartTime(); + LocalDateTime entryTime = stageStartTime.minusHours(12).minusSeconds(1); + LocalDateTime ticketOpenTime = stage.getTicketOpenTime(); + + // when & then + assertThatThrownBy(() -> ticket.addTicketEntryTime(ticketOpenTime.minusMinutes(10), entryTime, 100)) + .isInstanceOf(BadRequestException.class) + .hasMessage(EARLY_TICKET_ENTRY_TIME.getMessage()); + } + + @Test + void 티켓_오픈_이후_티켓생성시_예외() { + // given + Stage stage = StageFixture.builder() + .ticketOpenTime(LocalDateTime.now().minusHours(1)) + .build(); + Ticket ticket = TicketFixture.builder() + .build(); + + LocalDateTime startTime = stage.getStartTime(); + + // when & then + assertThatThrownBy(() -> ticket.addTicketEntryTime(LocalDateTime.now(), startTime.minusHours(3), 100)) + .isInstanceOf(BadRequestException.class) + .hasMessage(INVALID_TICKET_CREATE_TIME.getMessage()); + } + + @Test + void 입장시간을_추가한다() { + // given + Ticket ticket = TicketFixture + .builder() + .build(); + + Stage stage = ticket.getStage(); + LocalDateTime startTime = stage.getStartTime(); + LocalDateTime ticketOpenTime = stage.getTicketOpenTime(); + + // when + ticket.addTicketEntryTime(ticketOpenTime.minusMinutes(10), startTime.minusHours(3), 100); + ticket.addTicketEntryTime(ticketOpenTime.minusMinutes(10), startTime.minusHours(2), 200); + + // then + assertSoftly(softly -> { + softly.assertThat(ticket.getTicketAmount().getTotalAmount()).isEqualTo(300); + softly.assertThat(ticket.getTicketEntryTimes()).hasSize(2); + }); + } + } + + @Nested + class 티켓_정보_추출 { + + @Test + void 최대_수량보다_많으면_예외() { + // given + LocalDateTime stageStartTime = LocalDateTime.parse("2022-08-12T18:00:00"); + LocalDateTime now = stageStartTime.minusHours(6); + Festival festival = FestivalFixture.builder() + .startDate(stageStartTime.toLocalDate()) + .endDate(stageStartTime.toLocalDate()) + .build(); + Stage stage = StageFixture.builder() + .startTime(stageStartTime) + .ticketOpenTime(stageStartTime.minusDays(1)) + .festival(festival) + .build(); + Ticket ticket = TicketFixture.builder() + .stage(stage) + .build(); + Member member = MemberFixture.builder() + .id(1L) + .build(); + + ReservationSequence overSequence = new ReservationSequence(101); + + // when & then + assertThatThrownBy(() -> ticket.extractTicketInfo(overSequence)) + .isInstanceOf(BadRequestException.class) + .hasMessage(TICKET_SOLD_OUT.getMessage()); + } + } +} diff --git a/backend/src/test/java/com/festago/ticket/repository/TicketRepositoryTest.java b/backend/src/test/java/com/festago/ticket/repository/TicketRepositoryTest.java new file mode 100644 index 000000000..46fadff84 --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/repository/TicketRepositoryTest.java @@ -0,0 +1,59 @@ +package com.festago.ticket.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageRepository; +import com.festago.support.RepositoryTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.TicketFixture; +import com.festago.ticket.domain.Ticket; +import com.festago.ticket.domain.TicketType; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@RepositoryTest +class TicketRepositoryTest { + + @Autowired + TicketRepository ticketRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Test + void 공연의_ID로_티켓을_모두_조회() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + Stage stage = stageRepository.save(StageFixture.builder().festival(festival).build()); + Stage otherStage = stageRepository.save(StageFixture.builder().festival(festival).build()); + + ticketRepository.save(TicketFixture.builder().stage(stage).ticketType(TicketType.STUDENT).build()); + ticketRepository.save(TicketFixture.builder().stage(stage).ticketType(TicketType.VISITOR).build()); + ticketRepository.save(TicketFixture.builder().stage(otherStage).build()); + + // when + List actual = ticketRepository.findAllByStageIdWithFetch(stage.getId()); + + // then + assertThat(actual).hasSize(2); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/application/MemberTicketServiceTest.java b/backend/src/test/java/com/festago/ticketing/application/MemberTicketServiceTest.java new file mode 100644 index 000000000..2a18bb726 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/application/MemberTicketServiceTest.java @@ -0,0 +1,234 @@ +package com.festago.ticketing.application; + +import static com.festago.common.exception.ErrorCode.MEMBER_TICKET_NOT_FOUND; +import static com.festago.common.exception.ErrorCode.NOT_MEMBER_TICKET_OWNER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.NotFoundException; +import com.festago.member.domain.Member; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.MemberTicketFixture; +import com.festago.ticketing.domain.MemberTicket; +import com.festago.ticketing.dto.MemberTicketResponse; +import com.festago.ticketing.dto.MemberTicketsResponse; +import com.festago.ticketing.repository.MemberTicketRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class MemberTicketServiceTest { + + @Mock + MemberTicketRepository memberTicketRepository; + + @Spy + Clock clock = Clock.systemDefaultZone(); + + @InjectMocks + MemberTicketService memberTicketService; + + @Nested + class 멤버_티켓_아이디로_단건_조회 { + + @Test + void 멤버_티켓이_없으면_예외() { + // given + Long memberId = 1L; + Long memberTicketId = 1L; + + given(memberTicketRepository.findById(memberTicketId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberTicketService.findById(memberId, memberTicketId)) + .isInstanceOf(NotFoundException.class) + .hasMessage(MEMBER_TICKET_NOT_FOUND.getMessage()); + } + + @Test + void 사용자가_티켓의_주인이_아니면_예외() { + // given + Long memberId = 1L; + Long memberTicketId = 1L; + Member other = MemberFixture.builder() + .id(2L) + .build(); + + MemberTicket otherMemberTicket = MemberTicketFixture.builder() + .id(memberTicketId) + .owner(other) + .build(); + + given(memberTicketRepository.findById(memberTicketId)) + .willReturn(Optional.of(otherMemberTicket)); + + // when & then + assertThatThrownBy(() -> memberTicketService.findById(memberId, memberTicketId)) + .isInstanceOf(BadRequestException.class) + .hasMessage(NOT_MEMBER_TICKET_OWNER.getMessage()); + } + + @Test + void 성공() { + // given + Long memberId = 1L; + Long memberTicketId = 1L; + Member member = MemberFixture.builder() + .id(memberId) + .build(); + MemberTicket memberTicket = MemberTicketFixture.builder() + .id(memberTicketId) + .owner(member) + .build(); + + given(memberTicketRepository.findById(memberTicketId)) + .willReturn(Optional.of(memberTicket)); + + // when + MemberTicketResponse response = memberTicketService.findById(memberId, memberTicketId); + + // then + assertThat(response.id()).isEqualTo(memberTicketId); + } + } + + @Nested + class 사용자의_멤버티켓_전체_조회 { + + @Test + void 멤버_티켓이_없으면_빈_리스트() { + // given + Long memberId = 1L; + + // when + MemberTicketsResponse response = memberTicketService.findAll(memberId, PageRequest.ofSize(1)); + + // then + assertThat(response.memberTickets()).isEmpty(); + } + + @Test + void 성공() { + // given + Long memberId = 1L; + MemberTicket first = MemberTicketFixture.builder() + .id(1L) + .build(); + MemberTicket second = MemberTicketFixture.builder() + .id(2L) + .build(); + given(memberTicketRepository.findAllByOwnerId(eq(memberId), any(Pageable.class))) + .willReturn(List.of(first, second)); + + // when + MemberTicketsResponse response = memberTicketService.findAll(memberId, PageRequest.ofSize(1)); + + // then + assertThat(response.memberTickets()).hasSize(2); + } + } + + @Nested + class 현재_멤버티켓_조회 { + + @Test + void 입장시간이_24시간_지난_티켓은_조회되지_않는다() { + // given + Long memberId = 1L; + MemberTicket memberTicket = MemberTicketFixture.builder() + .entryTime(LocalDateTime.now().minusHours(25)) + .build(); + + given(memberTicketRepository.findAllByOwnerId(anyLong(), any(Pageable.class))) + .willReturn(List.of(memberTicket)); + + // when + MemberTicketsResponse response = memberTicketService.findCurrent(memberId, Pageable.ofSize(100)); + + // then + assertThat(response.memberTickets()).isEmpty(); + } + + @Test + void 활성화된_티켓이_먼저_조회된다() { + // given + Long memberId = 1L; + MemberTicket pendingMemberTicket = MemberTicketFixture.builder() + .id(1L) + .entryTime(LocalDateTime.now().plusHours(1)) + .build(); + MemberTicket activateMemberTicket = MemberTicketFixture.builder() + .id(2L) + .entryTime(LocalDateTime.now().minusHours(1)) + .build(); + + given(memberTicketRepository.findAllByOwnerId(eq(memberId), any(Pageable.class))) + .willReturn(List.of(pendingMemberTicket, activateMemberTicket)); + + // when + MemberTicketsResponse response = memberTicketService.findCurrent(memberId, Pageable.ofSize(100)); + + // then + List memberTicketIds = response.memberTickets().stream() + .map(MemberTicketResponse::id) + .toList(); + assertThat(memberTicketIds).containsExactly(2L, 1L); + } + + @Test + void 활성화_및_비활성화_내에서는_현재시간과_가까운순으로_정렬되어_조회된다() { + // given + Long memberId = 1L; + MemberTicket pendingMemberTicket1 = MemberTicketFixture.builder() + .id(1L) + .entryTime(LocalDateTime.now().plusHours(1)) + .build(); + MemberTicket pendingMemberTicket2 = MemberTicketFixture.builder() + .id(2L) + .entryTime(LocalDateTime.now().plusHours(2)) + .build(); + MemberTicket activateMemberTicket1 = MemberTicketFixture.builder() + .id(3L) + .entryTime(LocalDateTime.now().minusHours(2)) + .build(); + MemberTicket activateMemberTicket2 = MemberTicketFixture.builder() + .id(4L) + .entryTime(LocalDateTime.now().minusHours(1)) + .build(); + + given(memberTicketRepository.findAllByOwnerId(eq(memberId), any(Pageable.class))) + .willReturn( + List.of(pendingMemberTicket1, pendingMemberTicket2, activateMemberTicket1, activateMemberTicket2)); + + // when + MemberTicketsResponse response = memberTicketService.findCurrent(memberId, Pageable.ofSize(100)); + + // then + List memberTicketIds = response.memberTickets().stream() + .map(MemberTicketResponse::id) + .toList(); + assertThat(memberTicketIds).containsExactly(4L, 3L, 1L, 2L); + } + } +} diff --git a/backend/src/test/java/com/festago/ticketing/application/TicketingServiceTest.java b/backend/src/test/java/com/festago/ticketing/application/TicketingServiceTest.java index d7a144f9c..70b038b6a 100644 --- a/backend/src/test/java/com/festago/ticketing/application/TicketingServiceTest.java +++ b/backend/src/test/java/com/festago/ticketing/application/TicketingServiceTest.java @@ -11,8 +11,8 @@ import com.festago.member.repository.MemberRepository; import com.festago.stage.domain.Stage; import com.festago.student.repository.StudentRepository; -import com.festago.support.MemberFixture; -import com.festago.support.TicketFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.TicketFixture; import com.festago.ticket.domain.TicketType; import com.festago.ticket.repository.TicketAmountRepository; import com.festago.ticket.repository.TicketRepository; @@ -60,12 +60,12 @@ class TicketingServiceTest { // given TicketingRequest request = new TicketingRequest(1L); given(memberRepository.findById(anyLong())) - .willReturn(Optional.of(MemberFixture.member().build())); + .willReturn(Optional.of(MemberFixture.builder().build())); given(memberTicketRepository.existsByOwnerAndStage(any(Member.class), any(Stage.class))) .willReturn(false); given(ticketRepository.findByIdWithFetch(anyLong())) - .willReturn(Optional.of(TicketFixture.ticket().ticketType(TicketType.STUDENT).build())); + .willReturn(Optional.of(TicketFixture.builder().ticketType(TicketType.STUDENT).build())); given(studentRepository.existsByMemberAndSchoolId(any(Member.class), anyLong())) .willReturn(false); diff --git a/backend/src/test/java/com/festago/ticketing/application/integration/MemberTicketIntegrationTest.java b/backend/src/test/java/com/festago/ticketing/application/integration/MemberTicketIntegrationTest.java new file mode 100644 index 000000000..2561a9376 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/application/integration/MemberTicketIntegrationTest.java @@ -0,0 +1,75 @@ +package com.festago.ticketing.application.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.member.domain.Member; +import com.festago.member.repository.MemberRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.MemberTicketFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.ticket.repository.TicketRepository; +import com.festago.ticketing.application.MemberTicketService; +import com.festago.ticketing.dto.MemberTicketsResponse; +import com.festago.ticketing.repository.MemberTicketRepository; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberTicketIntegrationTest extends ApplicationIntegrationTest { + + @Autowired + MemberTicketService memberTicketService; + + @Autowired + MemberRepository memberRepository; + + @Autowired + MemberTicketRepository memberTicketRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + TicketRepository ticketRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Test + void 예매한_티켓_조회시_Pageable_적용() { + // given + School school = schoolRepository.save(SchoolFixture.builder().build()); + Member member = memberRepository.save(MemberFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + Stage stage = stageRepository.save(StageFixture.builder().festival(festival).build()); + for (int i = 0; i < 20; i++) { + memberTicketRepository.save(MemberTicketFixture.builder() + .stage(stage) + .owner(member) + .build() + ); + } + + // when + MemberTicketsResponse actual = memberTicketService.findAll(member.getId(), PageRequest.of(0, 10)); + + // then + assertThat(actual.memberTickets()).hasSize(10); + } +} diff --git a/backend/src/test/java/com/festago/application/integration/TicketingServiceIntegrationTest.java b/backend/src/test/java/com/festago/ticketing/application/integration/TicketingServiceIntegrationTest.java similarity index 91% rename from backend/src/test/java/com/festago/application/integration/TicketingServiceIntegrationTest.java rename to backend/src/test/java/com/festago/ticketing/application/integration/TicketingServiceIntegrationTest.java index 78fc657a8..7f6cdcb76 100644 --- a/backend/src/test/java/com/festago/application/integration/TicketingServiceIntegrationTest.java +++ b/backend/src/test/java/com/festago/ticketing/application/integration/TicketingServiceIntegrationTest.java @@ -1,4 +1,4 @@ -package com.festago.application.integration; +package com.festago.ticketing.application.integration; import static com.festago.common.exception.ErrorCode.RESERVE_TICKET_OVER_AMOUNT; import static org.assertj.core.api.Assertions.assertThat; @@ -11,7 +11,8 @@ import com.festago.member.repository.MemberRepository; import com.festago.school.repository.SchoolRepository; import com.festago.stage.domain.Stage; -import com.festago.support.MemberFixture; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.MemberFixture; import com.festago.ticketing.application.TicketingService; import com.festago.ticketing.dto.TicketingRequest; import com.festago.ticketing.repository.MemberTicketRepository; @@ -53,7 +54,7 @@ class TicketingServiceIntegrationTest extends ApplicationIntegrationTest { void 동시에_100명이_예약() { // given int tryCount = 100; - Member member = memberRepository.save(MemberFixture.member().build()); + Member member = memberRepository.save(MemberFixture.builder().build()); TicketingRequest request = new TicketingRequest(1L); ExecutorService executor = Executors.newFixedThreadPool(16); doReturn(false) @@ -79,7 +80,7 @@ class TicketingServiceIntegrationTest extends ApplicationIntegrationTest { @Sql("/ticketing-test-data.sql") void 하나의_공연에_중복으로_티켓을_예매하면_예외() { // given - Member member = memberRepository.save(MemberFixture.member().build()); + Member member = memberRepository.save(MemberFixture.builder().build()); TicketingRequest request = new TicketingRequest(1L); Long memberId = member.getId(); doReturn(Instant.parse("2023-07-24T03:21:31Z")) diff --git a/backend/src/test/java/com/festago/ticketing/domain/EntryStateTest.java b/backend/src/test/java/com/festago/ticketing/domain/EntryStateTest.java new file mode 100644 index 000000000..23352d449 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/domain/EntryStateTest.java @@ -0,0 +1,40 @@ +package com.festago.ticketing.domain; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.UnexpectedException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class EntryStateTest { + + @ParameterizedTest + @ValueSource(ints = {-1, 3}) + void 유효하지않은_인덱스로_생성시_예외(int index) { + // when & then + assertThatThrownBy(() -> EntryState.from(index)) + .isInstanceOf(UnexpectedException.class) + .hasMessageStartingWith("entryState의 인덱스가 올바르지 않습니다."); + } + + @Test + void 인덱스로_생성시_인자가_null이면_예외() { + // when & then + assertThatThrownBy(() -> EntryState.from(null)) + .isInstanceOf(UnexpectedException.class) + .hasMessage("entryState의 인덱스는 null이 될 수 없습니다."); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2}) + void 인덱스로_생성_성공(int index) { + // when & then + assertThatNoException().isThrownBy(() -> EntryState.from(index)); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/domain/MemberTicketTest.java b/backend/src/test/java/com/festago/ticketing/domain/MemberTicketTest.java new file mode 100644 index 000000000..dffeb9e8c --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/domain/MemberTicketTest.java @@ -0,0 +1,281 @@ +package com.festago.ticketing.domain; + +import static com.festago.ticketing.domain.EntryState.AFTER_ENTRY; +import static com.festago.ticketing.domain.EntryState.AWAY; +import static com.festago.ticketing.domain.EntryState.BEFORE_ENTRY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.member.domain.Member; +import com.festago.stage.domain.Stage; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.MemberTicketFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.TicketFixture; +import com.festago.ticket.domain.ReservationSequence; +import com.festago.ticket.domain.Ticket; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class MemberTicketTest { + + @Nested + class 티켓_생성 { + + @Test + void 예매_시간_이후에_생성하면_예외() { + // given + LocalDateTime stageStartTime = LocalDateTime.parse("2022-08-12T18:00:00"); + LocalDateTime now = stageStartTime.plusHours(1); + Festival festival = FestivalFixture.builder() + .startDate(stageStartTime.toLocalDate()) + .endDate(stageStartTime.toLocalDate()) + .build(); + Stage stage = StageFixture.builder() + .startTime(stageStartTime) + .ticketOpenTime(stageStartTime.minusDays(1)) + .festival(festival) + .build(); + Ticket ticket = TicketFixture.builder() + .stage(stage) + .build(); + Member member = MemberFixture.builder() + .id(1L) + .build(); + + ReservationSequence sequence = new ReservationSequence(1); + + // when & then + assertThatThrownBy(() -> MemberTicket.createMemberTicket(ticket, member, sequence, now)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.TICKET_CANNOT_RESERVE_STAGE_START.getMessage()); + } + + @ParameterizedTest + @ValueSource(ints = {1, 100}) + void 성공(int reservationSequence) { + // given + LocalDateTime stageStartTime = LocalDateTime.parse("2022-08-12T18:00:00"); + LocalDateTime now = stageStartTime.minusHours(6); + Festival festival = FestivalFixture.builder() + .startDate(stageStartTime.toLocalDate()) + .endDate(stageStartTime.toLocalDate()) + .build(); + Stage stage = StageFixture.builder() + .startTime(stageStartTime) + .ticketOpenTime(stageStartTime.minusDays(1)) + .festival(festival) + .build(); + Ticket ticket = TicketFixture.builder() + .stage(stage) + .build(); + Member member = MemberFixture.builder() + .id(1L) + .build(); + + ticket.addTicketEntryTime(LocalDateTime.MIN, stageStartTime.minusHours(1), 50); + ticket.addTicketEntryTime(LocalDateTime.MIN, stageStartTime.minusHours(2), 30); + ticket.addTicketEntryTime(LocalDateTime.MIN, stageStartTime.minusHours(3), 20); + + ReservationSequence sequence = new ReservationSequence(reservationSequence); + + // when + MemberTicket memberTicket = MemberTicket.createMemberTicket(ticket, member, sequence, now); + + // then + assertThat(memberTicket.getOwner()).isEqualTo(member); + } + } + + @Nested + class 입장_가능_여부_검사 { + + @Test + void 입장시간_전_입장_불가() { + // given + LocalDateTime entryTime = LocalDateTime.now(); + LocalDateTime time = entryTime.minusMinutes(10); + + MemberTicket memberTicket = MemberTicketFixture.builder() + .entryTime(entryTime) + .build(); + + // when && then + assertThat(memberTicket.canEntry(time)).isFalse(); + } + + @Test + void 입장시간_24시간이_지나면_입장_불가() { + // given + LocalDateTime entryTime = LocalDateTime.now(); + LocalDateTime time = entryTime.plusHours(24); + + MemberTicket memberTicket = MemberTicketFixture.builder() + .entryTime(entryTime) + .build(); + + // when && then + assertThat(memberTicket.canEntry(time)).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"2023-07-28T17:59:59", "2023-07-27T18:00:00"}) + void 입장_가능(LocalDateTime time) { + // given + LocalDateTime entryTime = LocalDateTime.parse("2023-07-27T18:00:00"); + Festival festival = FestivalFixture.builder() + .startDate(entryTime.toLocalDate()) + .endDate(entryTime.plusDays(4).toLocalDate()) + .build(); + Stage stage = StageFixture.builder() + .startTime(entryTime.plusHours(4)) + .ticketOpenTime(entryTime.minusWeeks(1)) + .festival(festival) + .build(); + MemberTicket memberTicket = MemberTicketFixture.builder() + .stage(stage) + .entryTime(entryTime) + .build(); + + // when && then + assertThat(memberTicket.canEntry(time)).isTrue(); + } + } + + @Nested + class 대기상태_티켓_검사 { + + @Test + void 입장시간_이후이면_거짓() { + // given + LocalDateTime entryTime = LocalDateTime.now(); + LocalDateTime time = entryTime.plusHours(1); + + MemberTicket memberTicket = MemberTicketFixture.builder() + .entryTime(entryTime) + .build(); + + // when & then + assertThat(memberTicket.isBeforeEntry(time)).isFalse(); + } + + @Test + void 입장시간_이전이면_참() { + // given + LocalDateTime entryTime = LocalDateTime.now(); + LocalDateTime time = entryTime.minusHours(12).plusSeconds(1); + Festival festival = FestivalFixture.builder() + .startDate(entryTime.toLocalDate()) + .endDate(entryTime.plusDays(4).toLocalDate()) + .build(); + Stage stage = StageFixture.builder() + .startTime(entryTime.plusHours(4)) + .festival(festival) + .build(); + MemberTicket memberTicket = MemberTicketFixture.builder() + .stage(stage) + .entryTime(entryTime) + .build(); + + // when & then + assertThat(memberTicket.isBeforeEntry(time)).isTrue(); + } + } + + @Nested + class 출입_상태_변경 { + + @Test + void 상태_변경시_기존의_상태와_다르면_기존_상태가_유지된다() { + // given + MemberTicket memberTicket = MemberTicketFixture.builder().build(); + + // when + memberTicket.changeState(AFTER_ENTRY); + + // then + assertThat(memberTicket.getEntryState()).isEqualTo(BEFORE_ENTRY); + } + + @Test + void 출입_전_상태에서_상태를_변경하면_출입_후_상태로_변경() { + // given + MemberTicket memberTicket = MemberTicketFixture.builder().build(); + + // when + memberTicket.changeState(BEFORE_ENTRY); + + // then + assertThat(memberTicket.getEntryState()).isEqualTo(AFTER_ENTRY); + } + + @Test + void 출입_후_상태에서_상태를_변경하면_외출_상태로_변경() { + // given + MemberTicket memberTicket = MemberTicketFixture.builder().build(); + memberTicket.changeState(BEFORE_ENTRY); + + // when + memberTicket.changeState(AFTER_ENTRY); + + // then + assertThat(memberTicket.getEntryState()).isEqualTo(AWAY); + } + + @Test + void 외출_상태에서_상태를_변경하면_출입_후_상태로_변경() { + // given + MemberTicket memberTicket = MemberTicketFixture.builder().build(); + memberTicket.changeState(BEFORE_ENTRY); + memberTicket.changeState(AFTER_ENTRY); + + // when + memberTicket.changeState(AWAY); + + // then + assertThat(memberTicket.getEntryState()).isEqualTo(AFTER_ENTRY); + } + } + + @Nested + class 티켓_주인_검사 { + + @Test + void 티켓_주인이다() { + // given + Long memberId = 1L; + Member member = MemberFixture.builder().id(memberId).build(); + MemberTicket memberTicket = MemberTicketFixture.builder() + .owner(member) + .build(); + + // when && then + assertThat(memberTicket.isOwner(memberId)).isTrue(); + } + + @Test + void 티켓_주인이_아니다() { + // given + Long memberId = 1L; + Long ownerId = 2L; + Member owner = MemberFixture.builder().id(ownerId).build(); + MemberTicket memberTicket = MemberTicketFixture.builder() + .owner(owner) + .build(); + + // when && then + assertThat(memberTicket.isOwner(memberId)).isFalse(); + } + } +} diff --git a/backend/src/test/java/com/festago/ticketing/repository/MemberTicketRepositoryTest.java b/backend/src/test/java/com/festago/ticketing/repository/MemberTicketRepositoryTest.java new file mode 100644 index 000000000..5d3d9960c --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/repository/MemberTicketRepositoryTest.java @@ -0,0 +1,133 @@ +package com.festago.ticketing.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.common.domain.BaseTimeEntity; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.member.domain.Member; +import com.festago.member.repository.MemberRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageRepository; +import com.festago.support.RepositoryTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.MemberFixture; +import com.festago.support.fixture.MemberTicketFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.ticket.repository.TicketRepository; +import com.festago.ticketing.domain.MemberTicket; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@RepositoryTest +class MemberTicketRepositoryTest { + + @Autowired + MemberTicketRepository memberTicketRepository; + + @Autowired + MemberRepository memberRepository; + + @Autowired + TicketRepository ticketRepository; + + @Autowired + StageRepository stageRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Nested + class 회원의_ID로_에매한_티켓을_모두_조회 { + + @Test + void 성공() { + // given + Member member1 = memberRepository.save(MemberFixture.builder().socialId("abc").build()); + Member member2 = memberRepository.save(MemberFixture.builder().socialId("def").build()); + + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + Stage stage1 = stageRepository.save(StageFixture.builder().festival(festival).build()); + Stage stage2 = stageRepository.save(StageFixture.builder().festival(festival).build()); + + memberTicketRepository.save(MemberTicketFixture.builder().stage(stage1).owner(member1).build()); + memberTicketRepository.save(MemberTicketFixture.builder().stage(stage2).owner(member1).build()); + memberTicketRepository.save(MemberTicketFixture.builder().stage(stage1).owner(member2).build()); + + // when + List memberTickets = memberTicketRepository.findAllByOwnerId(member1.getId(), + PageRequest.of(0, 10)); + + // then + assertThat(memberTickets).hasSize(2); + } + + @Test + void 지정한_갯수만큼_조회() { + // given + int expected = 10; + Member member = memberRepository.save(MemberFixture.builder().build()); + + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + Stage stage = stageRepository.save(StageFixture.builder().festival(festival).build()); + + for (int i = 0; i < 20; i++) { + memberTicketRepository.save(MemberTicketFixture.builder().stage(stage).owner(member).build()); + } + + // when + List memberTickets = memberTicketRepository.findAllByOwnerId(member.getId(), + PageRequest.of(0, expected)); + + // then + assertThat(memberTickets).hasSize(expected); + } + + @Test + void 지정한_정렬으로_조회() { + // given + Member member = memberRepository.save(MemberFixture.builder().build()); + + School school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + Stage stage = stageRepository.save(StageFixture.builder().festival(festival).build()); + + List memberTickets = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + memberTickets.add(MemberTicketFixture.builder().stage(stage).owner(member).build()); + } + memberTicketRepository.saveAll(memberTickets); + + Pageable pageable = PageRequest.of(0, 100, Sort.by("entryTime").descending()); + + // when + List actual = memberTicketRepository.findAllByOwnerId(member.getId(), pageable); + + // then + List expected = memberTickets.stream() + .sorted(Comparator.comparing(BaseTimeEntity::getCreatedAt).reversed()) + .toList(); + + assertThat(actual).isEqualTo(expected); + } + } +} diff --git a/backend/src/test/java/com/festago/upload/application/ImageFileUploadServiceTest.java b/backend/src/test/java/com/festago/upload/application/ImageFileUploadServiceTest.java new file mode 100644 index 000000000..60a752fbd --- /dev/null +++ b/backend/src/test/java/com/festago/upload/application/ImageFileUploadServiceTest.java @@ -0,0 +1,100 @@ +package com.festago.upload.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.upload.domain.FileOwnerType; +import com.festago.upload.domain.UploadFile; +import com.festago.upload.domain.UploadStatus; +import com.festago.upload.dto.FileUploadResult; +import com.festago.upload.infrastructure.FakeStorageClient; +import com.festago.upload.repository.MemoryUploadFileRepository; +import com.festago.upload.repository.UploadFileRepository; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ImageFileUploadServiceTest { + + ImageFileUploadService imageFileUploadService; + + UploadFileRepository uploadFileRepository; + + @BeforeEach + void setUp() { + uploadFileRepository = new MemoryUploadFileRepository(); + imageFileUploadService = new ImageFileUploadService( + new FakeStorageClient(), + uploadFileRepository + ); + } + + @ParameterizedTest + @ValueSource(strings = {"image.png", "image.jpg", "image.jpeg"}) + void 이미지를_업로드할때_JPG_PNG_확장자이면_성공한다(String filename) { + // given + MultipartFile multipartFile = new MockMultipartFile("image", filename, "image/png", + "data".getBytes(StandardCharsets.UTF_8)); + + // when + FileUploadResult result = imageFileUploadService.upload(multipartFile, null, null); + + // then + UUID uploadFileId = result.uploadFileId(); + assertThat(uploadFileRepository.findById(uploadFileId)).isPresent(); + } + + @ParameterizedTest + @ValueSource(strings = {"image", "image.exe", "image.txt", "image.gif"}) + void 이미지를_업로드할떄_JPG_PNG_확장자가_아니면_실패한다(String filename) { + // given + MultipartFile multipartFile = new MockMultipartFile("image", filename, "image/png", + "data".getBytes(StandardCharsets.UTF_8)); + + // when & then + assertThatThrownBy(() -> imageFileUploadService.upload(multipartFile, null, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.NOT_SUPPORT_FILE_EXTENSION.getMessage()); + } + + @Test + void 이미지를_업로드할때__ownerId_ownerType이_null이면_UPLOADED_상태로_영속된다() { + // given + MultipartFile multipartFile = new MockMultipartFile("image", "image.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8)); + + // when + FileUploadResult result = imageFileUploadService.upload(multipartFile, null, null); + + // then + UUID uploadFileId = result.uploadFileId(); + UploadFile uploadFile = uploadFileRepository.findById(uploadFileId).get(); + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.UPLOADED); + } + + @Test + void 이미지를_업로드할때_ownerId_ownerType이_null이_아니면_ASSGINED_상태로_영속된다() { + // given + MultipartFile multipartFile = new MockMultipartFile("image", "image.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8)); + + // when + FileUploadResult result = imageFileUploadService.upload(multipartFile, 1L, FileOwnerType.SCHOOL); + + // then + UUID uploadFileId = result.uploadFileId(); + UploadFile uploadFile = uploadFileRepository.findById(uploadFileId).get(); + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ASSIGNED); + } +} diff --git a/backend/src/test/java/com/festago/upload/application/UploadFileDeleteServiceTest.java b/backend/src/test/java/com/festago/upload/application/UploadFileDeleteServiceTest.java new file mode 100644 index 000000000..252a30880 --- /dev/null +++ b/backend/src/test/java/com/festago/upload/application/UploadFileDeleteServiceTest.java @@ -0,0 +1,147 @@ +package com.festago.upload.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.spy; + +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.UploadFileFixture; +import com.festago.upload.domain.UploadFile; +import com.festago.upload.infrastructure.FakeStorageClient; +import com.festago.upload.repository.MemoryUploadFileRepository; +import com.festago.upload.repository.UploadFileRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class UploadFileDeleteServiceTest { + + UploadFileDeleteService uploadFileDeleteService; + + UploadFileRepository uploadFileRepository; + + Clock clock; + + @BeforeEach + void setUp() { + uploadFileRepository = new MemoryUploadFileRepository(); + clock = spy(Clock.systemDefaultZone()); + uploadFileDeleteService = new UploadFileDeleteService( + new FakeStorageClient(), + uploadFileRepository, + clock + ); + } + + @Nested + class deleteAbandonedStatusWithPeriod { + + LocalDateTime _6월_30일_18시_0분_0초 = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime _6월_30일_18시_0분_1초 = LocalDateTime.parse("2077-06-30T18:00:01"); + LocalDateTime _6월_30일_18시_0분_2초 = LocalDateTime.parse("2077-06-30T18:00:02"); + + @Test + void 삭제되는_파일은_시작일에_포함되고_종료일에도_포함된다() { + // given + UploadFile _6월_30일_18시_0분_0초_생성된_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_30일_18시_0분_0초).buildAbandoned()); + UploadFile _6월_30일_18시_0분_1초_생성된_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_30일_18시_0분_1초).buildAbandoned()); + UploadFile _6월_30일_18시_0분_2초_생성된_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_30일_18시_0분_2초).buildAbandoned()); + + // when + uploadFileDeleteService.deleteAbandonedStatusWithPeriod(_6월_30일_18시_0분_0초, _6월_30일_18시_0분_1초); + + // then + var expect = uploadFileRepository.findByIdIn(List.of( + _6월_30일_18시_0분_0초_생성된_파일.getId(), + _6월_30일_18시_0분_1초_생성된_파일.getId(), + _6월_30일_18시_0분_2초_생성된_파일.getId() + )); + assertThat(expect) + .map(UploadFile::getId) + .containsExactly(_6월_30일_18시_0분_2초_생성된_파일.getId()); + } + + @Test + void ABANDONED_상태의_파일만_삭제된다() { + // given + UploadFile UPLOADED_상태_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_30일_18시_0분_0초).build()); + UploadFile ABANDONED_상태_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_30일_18시_0분_0초).buildAbandoned()); + + // when + uploadFileDeleteService.deleteAbandonedStatusWithPeriod(_6월_30일_18시_0분_0초, _6월_30일_18시_0분_0초); + + // then + assertThat(uploadFileRepository.findById(UPLOADED_상태_파일.getId())).isPresent(); + assertThat(uploadFileRepository.findById(ABANDONED_상태_파일.getId())).isEmpty(); + } + } + + @Nested + class deleteOldUploadedStatus { + + LocalDateTime _6월_29일_17시_59분_59초 = LocalDateTime.parse("2077-06-29T17:59:59"); + LocalDateTime _6월_29일_18시_0분_0초 = LocalDateTime.parse("2077-06-29T18:00:00"); + LocalDateTime _6월_29일_18시_0분_1초 = LocalDateTime.parse("2077-06-29T18:00:01"); + LocalDateTime _6월_30일_18시_0분_1초 = LocalDateTime.parse("2077-06-30T18:00:01"); + + @Test + void 생성된지_정확히_하루가_지난_파일만_삭제된다() { + // given + UploadFile _6월_29일_17시_59분_59초_생성된_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_29일_17시_59분_59초).build()); + UploadFile _6월_29일_18시_0분_0초_생성된_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_29일_18시_0분_0초).build()); + UploadFile _6월_29일_18시_0분_1초_생성된_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_29일_18시_0분_1초).build()); + + LocalDateTime now = _6월_30일_18시_0분_1초; + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + + // when + uploadFileDeleteService.deleteOldUploadedStatus(); + + // then + var expect = uploadFileRepository.findByIdIn(List.of( + _6월_29일_17시_59분_59초_생성된_파일.getId(), + _6월_29일_18시_0분_0초_생성된_파일.getId(), + _6월_29일_18시_0분_1초_생성된_파일.getId() + )); + assertThat(expect) + .map(UploadFile::getId) + .containsExactly(_6월_29일_18시_0분_1초_생성된_파일.getId()); + } + + @Test + void UPLOADED_상태의_파일만_삭제된다() { + // given + UploadFile UPLOADED_상태_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_29일_18시_0분_0초).build()); + UploadFile ABANDONED_상태_파일 = uploadFileRepository.save( + UploadFileFixture.builder().createdAt(_6월_29일_18시_0분_0초).buildAbandoned()); + + LocalDateTime now = _6월_30일_18시_0분_1초; + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + + // when + uploadFileDeleteService.deleteOldUploadedStatus(); + + // then + assertThat(uploadFileRepository.findById(UPLOADED_상태_파일.getId())).isEmpty(); + assertThat(uploadFileRepository.findById(ABANDONED_상태_파일.getId())).isPresent(); + } + } +} diff --git a/backend/src/test/java/com/festago/upload/application/UploadFileStatusChangeServiceTest.java b/backend/src/test/java/com/festago/upload/application/UploadFileStatusChangeServiceTest.java new file mode 100644 index 000000000..87bf32248 --- /dev/null +++ b/backend/src/test/java/com/festago/upload/application/UploadFileStatusChangeServiceTest.java @@ -0,0 +1,138 @@ +package com.festago.upload.application; + +import static com.festago.upload.domain.FileOwnerType.FESTIVAL; +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.festival.repository.MemoryFestivalRepository; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.UploadFileFixture; +import com.festago.upload.domain.FileOwnerType; +import com.festago.upload.domain.UploadFile; +import com.festago.upload.domain.UploadStatus; +import com.festago.upload.repository.MemoryUploadFileRepository; +import com.festago.upload.repository.UploadFileRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class UploadFileStatusChangeServiceTest { + + FestivalRepository festivalRepository; + + UploadFileRepository uploadFileRepository; + + UploadFileStatusChangeService uploadFileStatusChangeService; + + @BeforeEach + void setUp() { + festivalRepository = new MemoryFestivalRepository(); + uploadFileRepository = new MemoryUploadFileRepository(); + uploadFileStatusChangeService = new UploadFileStatusChangeService( + uploadFileRepository + ); + } + + @Nested + class changeAttached { + + @Test + void 주인이_가진_이미지가_업로드된_파일이면_ATTACHED_상태로_변경된다() { + // given + UploadFile uploadFile = UploadFileFixture.builder().build(); + uploadFileRepository.save(uploadFile); + Festival festival = FestivalFixture.builder() + .posterImageUrl(uploadFile.getUploadUri().toString()) + .build(); + festivalRepository.save(festival); + + // when + Long festivalId = festival.getId(); + List festivalImages = List.of(festival.getPosterImageUrl()); + uploadFileStatusChangeService.changeAttached(festivalId, FESTIVAL, festivalImages); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ATTACHED); + assertThat(uploadFile.getOwnerId()).isEqualTo(festival.getId()); + } + } + + @Nested + class changeRenewal { + + @Test + void 주인이_파일을_가지고_있고_ASSIGNED_상태이면_ATTACHED_상태로_변경된다() { + // given + UploadFile uploadFile = UploadFileFixture.builder().build(); + uploadFileRepository.save(uploadFile); + Festival festival = FestivalFixture.builder() + .posterImageUrl(uploadFile.getUploadUri().toString()) + .build(); + festivalRepository.save(festival); + uploadFile.changeAssigned(festival.getId(), FESTIVAL); + + // when + Long festivalId = festival.getId(); + List festivalImages = List.of(festival.getPosterImageUrl()); + uploadFileStatusChangeService.changeRenewal(festivalId, FESTIVAL, festivalImages); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ATTACHED); + } + + @Test + void 주인이_파일을_가지고_있지_있고_ASSIGNED_상태이면_ABANDONED_상태로_변경된다() { + // given + Festival festival = FestivalFixture.builder() + .build(); + festivalRepository.save(festival); + + UploadFile uploadFile = UploadFileFixture.builder().build(); + uploadFile.changeAssigned(festival.getId(), FESTIVAL); + uploadFileRepository.save(uploadFile); + + + // when + Long festivalId = festival.getId(); + List festivalImages = List.of(festival.getPosterImageUrl()); + uploadFileStatusChangeService.changeRenewal(festivalId, FESTIVAL, festivalImages); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ABANDONED); + } + } + + @Nested + class changeAllAbandoned { + + @Test + void 주인의_모든_파일을_ABANDONED_상태로_변경한다() { + // given + UploadFile prevUploadFile = UploadFileFixture.builder().build(); + UploadFile uploadFile = UploadFileFixture.builder().build(); + Festival festival = FestivalFixture.builder() + .posterImageUrl(uploadFile.getUploadUri().toString()) + .build(); + festivalRepository.save(festival); + + prevUploadFile.changeAssigned(festival.getId(), FESTIVAL); + uploadFile.changeAttached(festival.getId(), FESTIVAL); + uploadFileRepository.save(prevUploadFile); + uploadFileRepository.save(uploadFile); + + // when + uploadFileStatusChangeService.changeAllAbandoned(festival.getId(), FESTIVAL); + + // then + assertThat(uploadFileRepository.findAllByOwnerIdAndOwnerType(festival.getId(), FESTIVAL)) + .map(UploadFile::getStatus) + .containsOnly(UploadStatus.ABANDONED); + } + } +} diff --git a/backend/src/test/java/com/festago/upload/domain/UploadFileTest.java b/backend/src/test/java/com/festago/upload/domain/UploadFileTest.java new file mode 100644 index 000000000..0a5615af3 --- /dev/null +++ b/backend/src/test/java/com/festago/upload/domain/UploadFileTest.java @@ -0,0 +1,309 @@ +package com.festago.upload.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.festago.common.exception.ValidException; +import com.festago.support.fixture.UploadFileFixture; +import java.net.URI; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class UploadFileTest { + + @Nested + class 생성 { + + private final int size = 1000; + private final URI location = URI.create("https://festago.com/image.png"); + private final FileExtension extension = FileExtension.PNG; + private final LocalDateTime createdAt = LocalDateTime.now(); + + @Test + void size가_음수이면_예외() { + // when & then + assertThatThrownBy(() -> new UploadFile(-1, location, extension, createdAt)) + .isInstanceOf(ValidException.class); + } + + @Test + void location이_null이면_예외() { + // when & then + assertThatThrownBy(() -> new UploadFile(size, null, extension, createdAt)) + .isInstanceOf(ValidException.class); + } + + @Test + void extension이_null이면_예외() { + // when & then + assertThatThrownBy(() -> new UploadFile(size, location, null, createdAt)) + .isInstanceOf(ValidException.class); + } + + @Test + void createdAt이_null이면_예외() { + // when & then + assertThatThrownBy(() -> new UploadFile(size, location, extension, null)) + .isInstanceOf(ValidException.class); + } + + @Test + void 생성된_UploadFile의_상태는_UPLOADED이다() { + // given + UploadFile uploadFile = new UploadFile(size, location, extension, createdAt); + + // when & then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.UPLOADED); + } + } + + @Nested + class 상태_변경 { + + @Test + void UPLOADED_상태의_UploadFile은_ASSIGNED_상태로_변경할_수_있다() { + // given + UploadFile uploadFile = UploadFileFixture.builder().build(); + + // when + uploadFile.changeAssigned(1L, FileOwnerType.FESTIVAL); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ASSIGNED); + } + + @ParameterizedTest(name = "{1}") + @MethodSource("uploadFilesWithoutUploaded") + void UPLOADED_상태가_아닌_UploadFile은_ASSIGNED_상태로_변하지_않는다(UploadFile uploadFile, UploadStatus status) { + // when + uploadFile.changeAssigned(1L, FileOwnerType.FESTIVAL); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(status); + } + + public static Stream uploadFilesWithoutUploaded() { + return Stream.of( + Arguments.arguments(UploadFileFixture.builder().buildAssigned(), UploadStatus.ASSIGNED), + Arguments.arguments(UploadFileFixture.builder().buildAttached(), UploadStatus.ATTACHED), + Arguments.arguments(UploadFileFixture.builder().buildAbandoned(), UploadStatus.ABANDONED) + ); + } + + @Test + void UPLOADED_상태의_UploadFile은_ATTACHED_상태로_변경할_수_있다() { + // given + UploadFile uploadFile = UploadFileFixture.builder().build(); + + // when + uploadFile.changeAttached(1L, FileOwnerType.FESTIVAL); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ATTACHED); + } + + @ParameterizedTest(name = "{1}") + @MethodSource("uploadFilesWithoutUploaded") + void UPLOADED_상태가_아닌_UploadFile은_ATTACHED_상태로_변하지_않는다(UploadFile uploadFile, UploadStatus status) { + // when + uploadFile.changeAttached(1L, FileOwnerType.FESTIVAL); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(status); + } + + @ParameterizedTest(name = "{1}") + @MethodSource("uploadFiles") + void 모든_상태의_UploadFile은_ABANDONED_상태로_변경할_수_있다(UploadFile uploadFile, UploadStatus status) { + // when + uploadFile.changeAbandoned(); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ABANDONED); + } + + public static Stream uploadFiles() { + return Stream.of( + Arguments.arguments(UploadFileFixture.builder().build(), UploadStatus.UPLOADED), + Arguments.arguments(UploadFileFixture.builder().buildAssigned(), UploadStatus.ASSIGNED), + Arguments.arguments(UploadFileFixture.builder().buildAttached(), UploadStatus.ATTACHED), + Arguments.arguments(UploadFileFixture.builder().buildAbandoned(), UploadStatus.ABANDONED) + ); + } + } + + @Nested + class renewalStatus { + + @Test + void UPLOADED_상태의_파일은_상태가_변경되지_않는다() { + // given + UploadFile uploadFile = UploadFileFixture.builder().build(); + + // when + uploadFile.renewalStatus(1L, FileOwnerType.FESTIVAL, Set.of(uploadFile.getId())); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.UPLOADED); + } + + @Test + void ABANDONED_상태의_파일은_상태가_변경되지_않는다() { + // given + UploadFile uploadFile = UploadFileFixture.builder() + .ownerId(1L) + .ownerType(FileOwnerType.FESTIVAL) + .buildAbandoned(); + + // when + uploadFile.renewalStatus(1L, FileOwnerType.FESTIVAL, Set.of(uploadFile.getId())); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ABANDONED); + } + + @Test + void ATTACHED_상태의_파일이고_식별자_목록에_자신이_포함되면_상태가_변하지_않는다() { + // given + UploadFile uploadFile = UploadFileFixture.builder() + .ownerId(1L) + .ownerType(FileOwnerType.FESTIVAL) + .buildAttached(); + + // when + uploadFile.renewalStatus(1L, FileOwnerType.FESTIVAL, Set.of(uploadFile.getId())); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ATTACHED); + } + + @Test + void ATTACHED_상태의_파일이고_식별자_목록에_자신이_포함되지_않으면_ABANDONED_상태로_변경된다() { + // given + UploadFile uploadFile = UploadFileFixture.builder() + .ownerId(1L) + .ownerType(FileOwnerType.FESTIVAL) + .buildAttached(); + + // when + uploadFile.renewalStatus(1L, FileOwnerType.FESTIVAL, Collections.emptySet()); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ABANDONED); + } + + @Test + void ASSIGNED_상태의_파일이고_식별자_목록에_자신이_포함되면_ATTACHED_상태로_변경된다() { + // given + UploadFile uploadFile = UploadFileFixture.builder() + .ownerId(1L) + .ownerType(FileOwnerType.FESTIVAL) + .buildAssigned(); + + // when + uploadFile.renewalStatus(1L, FileOwnerType.FESTIVAL, Set.of(uploadFile.getId())); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ATTACHED); + } + + @Test + void ASSIGNED_상태의_파일이고_식별자_목록에_자신이_포함되지_않으면_ABANDONED_상태로_변경된다() { + // given + UploadFile uploadFile = UploadFileFixture.builder() + .ownerId(1L) + .ownerType(FileOwnerType.FESTIVAL) + .buildAssigned(); + + // when + uploadFile.renewalStatus(1L, FileOwnerType.FESTIVAL, Collections.emptySet()); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ABANDONED); + } + + @Test + void 주인의_타입은_같아도_식별자가_다르면_상태가_변경되지_않는다() { + // given + UploadFile uploadFile = UploadFileFixture.builder() + .ownerId(2L) + .ownerType(FileOwnerType.FESTIVAL) + .buildAssigned(); + + // when + uploadFile.renewalStatus(1L, FileOwnerType.FESTIVAL, Set.of(uploadFile.getId())); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ASSIGNED); + } + + @Test + void 주인의_식별자는_같아도_타입이_다르면_상태가_변경되지_않는다() { + // given + UploadFile uploadFile = UploadFileFixture.builder() + .ownerId(1L) + .ownerType(FileOwnerType.SCHOOL) + .buildAssigned(); + + // when + uploadFile.renewalStatus(1L, FileOwnerType.FESTIVAL, Set.of(uploadFile.getId())); + + // then + assertThat(uploadFile.getStatus()).isEqualTo(UploadStatus.ASSIGNED); + } + } + + @Nested + class getUploadUri { + + @Test + void location에_날짜와_식별자와_확장자가_붙은_형식으로_반환된다() { + // given + UploadFile uploadFile = UploadFileFixture.builder() + .createdAt(LocalDateTime.parse("2077-06-30T18:00:00")) + .location(URI.create("https://festago.com")) + .extension(FileExtension.PNG) + .build(); + + // when + URI uploadUri = uploadFile.getUploadUri(); + + // then + UUID id = uploadFile.getId(); + assertThat(uploadUri).hasToString("https://festago.com/2077-06-30/" + id + ".png"); + } + } + + @Nested + class getName { + + @Test + void 날짜와_식별자와_확장자가_붙은_형식으로_반환된다() { + // given + UploadFile uploadFile = UploadFileFixture.builder() + .createdAt(LocalDateTime.parse("2077-06-30T18:00:00")) + .location(URI.create("https://festago.com")) + .extension(FileExtension.PNG) + .build(); + + // when + String filename = uploadFile.getName(); + + // then + UUID id = uploadFile.getId(); + assertThat(filename).isEqualTo("2077-06-30/" + id + ".png"); + } + } +} diff --git a/backend/src/test/java/com/festago/upload/infrastructure/FakeStorageClient.java b/backend/src/test/java/com/festago/upload/infrastructure/FakeStorageClient.java new file mode 100644 index 000000000..e57533708 --- /dev/null +++ b/backend/src/test/java/com/festago/upload/infrastructure/FakeStorageClient.java @@ -0,0 +1,20 @@ +package com.festago.upload.infrastructure; + +import com.festago.support.fixture.UploadFileFixture; +import com.festago.upload.domain.StorageClient; +import com.festago.upload.domain.UploadFile; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +public class FakeStorageClient implements StorageClient { + + @Override + public UploadFile storage(MultipartFile file) { + return UploadFileFixture.builder().build(); + } + + @Override + public void delete(List uploadFiles) { + // NOOP + } +} diff --git a/backend/src/test/java/com/festago/upload/repository/MemoryUploadFileRepository.java b/backend/src/test/java/com/festago/upload/repository/MemoryUploadFileRepository.java new file mode 100644 index 000000000..82a3c8462 --- /dev/null +++ b/backend/src/test/java/com/festago/upload/repository/MemoryUploadFileRepository.java @@ -0,0 +1,68 @@ +package com.festago.upload.repository; + +import com.festago.upload.domain.FileOwnerType; +import com.festago.upload.domain.UploadFile; +import com.festago.upload.domain.UploadStatus; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +public class MemoryUploadFileRepository implements UploadFileRepository { + + private final HashMap memory = new HashMap<>(); + + @Override + public UploadFile save(UploadFile uploadFile) { + memory.put(uploadFile.getId(), uploadFile); + return uploadFile; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(memory.get(id)); + } + + @Override + public List findAllByOwnerIdAndOwnerType(Long ownerId, FileOwnerType ownerType) { + return memory.values().stream() + .filter(it -> Objects.equals(it.getOwnerId(), ownerId)) + .filter(it -> Objects.equals(it.getOwnerType(), ownerType)) + .toList(); + } + + @Override + public List findByIdIn(Collection ids) { + return memory.values().stream() + .filter(uploadFile -> ids.contains(uploadFile.getId())) + .toList(); + } + + @Override + public List findByCreatedAtBetweenAndStatus(LocalDateTime startTime, LocalDateTime endTime, + UploadStatus status) { + return memory.values().stream() + .filter(it -> it.getStatus() == status) + .filter(it -> it.getCreatedAt().isEqual(startTime) || it.getCreatedAt().isAfter(startTime)) + .filter(it -> it.getCreatedAt().isEqual(endTime) || it.getCreatedAt().isBefore(endTime)) + .toList(); + } + + @Override + public List findByCreatedAtBeforeAndStatus(LocalDateTime createdAt, UploadStatus status) { + return memory.values().stream() + .filter(it -> it.getStatus() == status) + .filter(it -> it.getCreatedAt().isBefore(createdAt)) + .toList(); + } + + @Override + public void deleteByIn(List uploadFiles) { + for (UploadFile uploadFile : uploadFiles) { + memory.remove(uploadFile.getId()); + } + } +} diff --git a/backend/src/test/java/com/festago/upload/util/FilenameExtensionParserTest.java b/backend/src/test/java/com/festago/upload/util/FilenameExtensionParserTest.java new file mode 100644 index 000000000..9edbe7c32 --- /dev/null +++ b/backend/src/test/java/com/festago/upload/util/FilenameExtensionParserTest.java @@ -0,0 +1,72 @@ +package com.festago.upload.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FilenameExtensionParserTest { + + @Test + void 파일의_확장자가_없으면_빈_문자열이_반환된다() { + // given + String filename = "myFile"; + + // when + String extension = FileNameExtensionParser.parse(filename); + + // then + assertThat(extension).isEmpty(); + } + + @Test + void 파일의_이름에_점_뒤에_문자열이_없으면_빈_문자열이_반환된다() { + // given + String filename = "myFile."; + + // when + String extension = FileNameExtensionParser.parse(filename); + + // then + assertThat(extension).isEmpty(); + } + + @Test + void 파일의_확장자가_있으면_확장자가_반환된다() { + // given + String filename = "myFile.png"; + + // when + String extension = FileNameExtensionParser.parse(filename); + + // then + assertThat(extension).isEqualTo(".png"); + } + + @Test + void 파일의_이름에_점이_여러개면_마지막_점_기준_확장자가_반환된다() { + // given + String filename = "myFile.jpg.png"; + + // when + String extension = FileNameExtensionParser.parse(filename); + + // then + assertThat(extension).isEqualTo(".png"); + } + + @Test + void 파일의_마지막에_공백이_있어도_확장자가_반환된다() { + // given + String filename = "myFile.png "; + + // when + String extension = FileNameExtensionParser.parse(filename); + + // then + assertThat(extension).isEqualTo(".png"); + } +} diff --git a/backend/src/test/java/com/festago/upload/util/UriUploadFileIdParserTest.java b/backend/src/test/java/com/festago/upload/util/UriUploadFileIdParserTest.java new file mode 100644 index 000000000..452fc8bca --- /dev/null +++ b/backend/src/test/java/com/festago/upload/util/UriUploadFileIdParserTest.java @@ -0,0 +1,73 @@ +package com.festago.upload.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class UriUploadFileIdParserTest { + + @ParameterizedTest + @NullAndEmptySource + void URI가_null_또는_빈_문자열이면_빈_Optioanl이_반환된다(String uri) { + // when + Optional actual = UriUploadFileIdParser.parse(uri); + // then + assertThat(actual).isEmpty(); + } + + @Test + void UUID_형식이라도_확장자가_존재하지_않으면_빈_Optional이_반환된다() { + // given + String uri = "https://image.com/" + UUID.randomUUID(); + + // when + Optional actual = UriUploadFileIdParser.parse(uri); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void UUID_형식이_아니면_빈_Optional이_반환된다() { + // given + String uri = "https://image.com/image.png"; + + // when + Optional actual = UriUploadFileIdParser.parse(uri); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void URI의_형식에_스키마가_존재하지_않아도_값이_있는_Optional이_반환된다() { + // given + String uri = UUID.randomUUID() + ".png"; + + // when + Optional actual = UriUploadFileIdParser.parse(uri); + + // then + assertThat(actual).isPresent(); + } + + @Test + void 파일_이름이_UUID_형식이면_값이_있는_Optional이_반환된다() { + // given + String uri = "https://image.com/" + UUID.randomUUID() + ".png"; + + // when + Optional actual = UriUploadFileIdParser.parse(uri); + + // then + assertThat(actual).isPresent(); + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index 44d7f0166..d5f429a11 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -2,26 +2,36 @@ spring: datasource: url: jdbc:h2:mem:test;MODE=MYSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE jpa: - properties: - hibernate: - format_sql: true - show-sql: true + show-sql: false hibernate: ddl-auto: create open-in-view: false flyway: enabled: false +management: + server: + port: 8090 logging: file: path: ./ - level: - org: - hibernate: - orm: - jdbc: - bind: trace festago: qr-secret-key: festagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestago auth-secret-key: festagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestagofestago + cors-allow-origins: http://localhost:3000 + oauth2: + apple: + client-id: appleClientId + kakao: + grant-type: grant-type + rest-api-key: rest-api-key + native-app-key: native-app-key + redirect-uri: redirect-uri + client-secret: client-secret + r2: + access-key: access-key + secret-key: secret-key + endpoint: https://endpoint.com + bucket: bucket + url: https://url.com diff --git a/backend/src/test/resources/features/admin_school.feature b/backend/src/test/resources/features/admin_school.feature new file mode 100644 index 000000000..9e878a677 --- /dev/null +++ b/backend/src/test/resources/features/admin_school.feature @@ -0,0 +1,18 @@ +Feature: 어드민의 학교에 관한 기능 + + Background: 학교를 생성한다. + Given 어드민 계정을 활성화한다. + Given 어드민 계정으로 로그인한다. + Given 지역이 "서울"에 있고, 이름이 "테코대학교"이고, 도메인이 "teco.ac.kr"인 학교를 생성한다. + + Scenario: 생성된 학교가 조회되어야 한다. + Then 이름에 "테코대학교"가 포함된 학교가 조회되어야 한다. + + Scenario: 학교를 수정하면 기존 학교는 조회되지 않고, 수정된 학교가 조회되어야 한다. + When 이름이 "테코대학교"인 학교의 이름을 "우테대학교"로 변경한다. + Then 이름에 "테코대학교"가 포함된 학교가 조회되지 않는다. + Then 이름에 "우테대학교"가 포함된 학교가 조회되어야 한다. + + Scenario: 학교를 삭제하면 기존 학교는 조회되지 않는다. + When 이름이 "테코대학교"인 학교를 삭제한다. + Then 이름에 "테코대학교"가 포함된 학교가 조회되지 않는다. diff --git a/backend/src/test/resources/features/example.feature b/backend/src/test/resources/features/example.feature deleted file mode 100644 index 39f4eb86f..000000000 --- a/backend/src/test/resources/features/example.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: 축제 생성 - - Scenario: 성공 - Given 로그인을 한 상태에서 - Given "테코대학교"를 생성하고 - Given 축제를 생성하고 - When 축제를 검색하면 - Then 축제가 있다 - - Scenario: 의존성 테스트 - Then 전 시나리오에서 생성된 데이터는 없어진다 - diff --git a/backend/src/test/resources/features/query_festival.feature b/backend/src/test/resources/features/query_festival.feature new file mode 100644 index 000000000..c64cba2e1 --- /dev/null +++ b/backend/src/test/resources/features/query_festival.feature @@ -0,0 +1,35 @@ +Feature: 사용자가 축제를 조회하는 기능 + + Background: + Given 현재 시간은 2023년 7월 1일 15시 30분 이다. + Given 어드민 계정을 활성화한다. + Given 어드민 계정으로 로그인한다. + Given 지역이 "서울"에 있고, 이름이 "테코대학교"이고, 도메인이 "teco.ac.kr"인 학교를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 10일", 종료일이 "2023년 07월 12일", 이름이 "축제1"인 축제를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 06일", 종료일이 "2023년 07월 09일", 이름이 "축제2"인 축제를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 08일", 종료일이 "2023년 07월 10일", 이름이 "축제3"인 축제를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 12일", 종료일이 "2023년 07월 13일", 이름이 "축제4"인 축제를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 11일", 종료일이 "2023년 07월 11일", 이름이 "축제5"인 축제를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 09일", 종료일이 "2023년 07월 10일", 이름이 "축제6"인 축제를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 04일", 종료일이 "2023년 07월 06일", 이름이 "축제7"인 축제를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 08일", 종료일이 "2023년 07월 08일", 이름이 "축제8"인 축제를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 12일", 종료일이 "2023년 07월 12일", 이름이 "축제9"인 축제를 생성한다. + Given "테코대학교"에서 시작일이 "2023년 07월 05일", 종료일이 "2023년 07월 07일", 이름이 "축제10"인 축제를 생성한다. + + # TODO 순서가 제대로 나오는지 확인할 것 원래는 축제7, 축제10, 축제2 순서였음 + Scenario: 2023년 7월 6일에 진행중인 축제를 조회한다. + Given 현재 시간은 2023년 7월 6일 15시 30분 이다. + Given 어드민 계정으로 로그인한다. + Then 상태가 "PROGRESS"인 축제를 조회하면 3개의 축제가 조회된다. + And 조회된 축제 중에서 1번째 축제의 이름은 "축제2" 이어야 한다. + And 조회된 축제 중에서 2번째 축제의 이름은 "축제10" 이어야 한다. + And 조회된 축제 중에서 3번째 축제의 이름은 "축제7" 이어야 한다. + + Scenario: 2023년 7월 9일에 진행 예정인 축제를 조회한다. + Given 현재 시간은 2023년 7월 9일 15시 30분 이다. + Given 어드민 계정으로 로그인한다. + Then 상태가 "PLANNED"인 축제를 조회하면 4개의 축제가 조회된다. + And 조회된 축제 중에서 1번째 축제의 이름은 "축제1" 이어야 한다. + And 조회된 축제 중에서 2번째 축제의 이름은 "축제5" 이어야 한다. + And 조회된 축제 중에서 3번째 축제의 이름은 "축제4" 이어야 한다. + And 조회된 축제 중에서 4번째 축제의 이름은 "축제9" 이어야 한다. diff --git a/backend/src/test/resources/features/time.feature b/backend/src/test/resources/features/time.feature new file mode 100644 index 000000000..8be4f237c --- /dev/null +++ b/backend/src/test/resources/features/time.feature @@ -0,0 +1,8 @@ +Feature: 시간 설정을 검증한다. + + Scenario: 현재 시간을 Stub 할 수 있어야 한다. + Given 현재 시간은 2024년 6월 30일 15시 30분 이다. + Then 현재 시간은 2024년 6월 30일 15시 30분이 되어야 한다. + + Scenario: 이전 시나리오의 Stub이 적용되지 않아야 한다. + Then 현재 시간은 2024년 6월 30일 15시 30분이 아니어야 한다. diff --git a/backend/src/test/resources/ticketing-test-data.sql b/backend/src/test/resources/ticketing-test-data.sql index 58a8894dc..9eb35e5e9 100644 --- a/backend/src/test/resources/ticketing-test-data.sql +++ b/backend/src/test/resources/ticketing-test-data.sql @@ -1,11 +1,11 @@ insert into school (domain, name) values ('festago.com', '페스타고 대학교'); -insert into festival (school_id, end_date, name, start_date, thumbnail) +insert into festival (school_id, end_date, name, start_date, poster_image_url) values (1, '2023-07-30', '테코 대학교', '2023-08-02', ''); -insert into stage (festival_id, line_up, start_time, ticket_open_time) -values (1, '', '2023-07-30T03:21:31.964676', '2023-07-23T03:21:31.964676'); +insert into stage (festival_id, start_time, ticket_open_time) +values (1, '2023-07-30T03:21:31.964676', '2023-07-23T03:21:31.964676'); insert into ticket (school_id, stage_id, ticket_type) values (1, 1, 'VISITOR');